590 lines
20 KiB
Markdown
590 lines
20 KiB
Markdown
# ControlServices Subsystem Design
|
|
|
|
This document expands the `ControlServices` subsystem described in [PHASE_1_SUBSYSTEM_BOUNDARIES_DESIGN.md](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/docs/PHASE_1_SUBSYSTEM_BOUNDARIES_DESIGN.md). It defines the target role of `ControlServices` as the ingress boundary for non-render control sources and the normalization layer that turns external input into typed internal actions.
|
|
|
|
The intent here is to make `ControlServices` explicit enough that later phases can extract it from the current `RuntimeServices` / `ControlServer` / `OscServer` mix without inventing new boundaries ad hoc.
|
|
|
|
## Purpose
|
|
|
|
`ControlServices` is the subsystem that accepts external control traffic and turns it into safe, typed, low-cost input for the rest of the app.
|
|
|
|
In the target architecture, `ControlServices` should:
|
|
|
|
- own ingress for OSC, HTTP/REST-style control routes, WebSocket session management, and file-watch/reload signals
|
|
- normalize transport-specific payloads into typed internal actions or events
|
|
- apply ingress-local buffering, coalescing, deduplication, and rate limiting where useful
|
|
- expose service timing and health observations to `HealthTelemetry`
|
|
- forward normalized actions into `RuntimeCoordinator`
|
|
|
|
It should not:
|
|
|
|
- decide persistence policy
|
|
- mutate persisted state directly
|
|
- build render snapshots
|
|
- own render-local overlay state
|
|
- own device timing or playout policy
|
|
|
|
This subsystem is intentionally narrow in authority and broad in transport coverage.
|
|
|
|
## Why This Subsystem Exists
|
|
|
|
Today the app already has a recognizable control-services slice, but it is spread across several classes:
|
|
|
|
- `RuntimeServices` hosts control server startup, OSC queues, deferred OSC commits, and file-watch polling:
|
|
- [RuntimeServices.h](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/control/RuntimeServices.h:26)
|
|
- [RuntimeServices.cpp](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/control/RuntimeServices.cpp:24)
|
|
- `ControlServer` owns HTTP, WebSocket upgrade, static asset serving, and direct callback-based route dispatch:
|
|
- [ControlServer.h](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/control/ControlServer.h:15)
|
|
- [ControlServer.cpp](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/control/ControlServer.cpp:88)
|
|
- `OscServer` owns UDP socket receive, OSC decode, and parameter callback dispatch:
|
|
- [OscServer.h](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/control/OscServer.h:11)
|
|
- [OscServer.cpp](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/control/OscServer.cpp:58)
|
|
|
|
The current shape works, but it mixes:
|
|
|
|
- transport handling
|
|
- action normalization
|
|
- direct callback dispatch
|
|
- coarse background polling
|
|
- transient queue ownership
|
|
- UI broadcast behavior
|
|
- partial runtime mutation coordination
|
|
|
|
That overlap is exactly what Phase 1 is trying to remove.
|
|
|
|
## Design Goals
|
|
|
|
`ControlServices` should optimize for:
|
|
|
|
- low-latency ingress without forcing immediate whole-app work
|
|
- clear transport boundaries
|
|
- deterministic normalization of external input
|
|
- isolation of service-specific timing concerns
|
|
- easy replacement of polling flows with typed events
|
|
- no direct knowledge of render-local implementation details
|
|
- safe behavior under bursty traffic such as high-rate OSC
|
|
|
|
## Subsystem Responsibilities
|
|
|
|
`ControlServices` owns the following concerns.
|
|
|
|
### 1. Transport Ingress
|
|
|
|
It accepts input from external control-facing sources such as:
|
|
|
|
- OSC/UDP parameter control
|
|
- HTTP API requests from the native control UI or external clients
|
|
- WebSocket connection lifecycle for state consumers
|
|
- file-watch triggers and manual reload requests
|
|
- future automation ingress such as MIDI, serial, or remote control bridges
|
|
|
|
The key rule is that transport-specific details stop here.
|
|
|
|
### 2. Action Normalization
|
|
|
|
Every ingress path should be converted into a typed internal action or event before it touches runtime policy.
|
|
|
|
Examples:
|
|
|
|
- OSC `/layer/param` traffic becomes `AutomationTargetReceived`
|
|
- `POST /api/layers/add` becomes `LayerAddRequested`
|
|
- `POST /api/reload` becomes `ShaderReloadRequested`
|
|
- file-watch changes become `RegistryChangedDetected` or `ReloadRequested`
|
|
|
|
The rest of the app should not need to know whether an action came from UDP, HTTP, the embedded UI, or a background watcher.
|
|
|
|
### 3. Ingress-Local Buffering and Coalescing
|
|
|
|
`ControlServices` may maintain short-lived queues or coalesced maps when that is the correct place to absorb bursty input.
|
|
|
|
Examples:
|
|
|
|
- latest-value coalescing per OSC route
|
|
- pending reload edge detection
|
|
- bounded outbound state-broadcast requests
|
|
- short-lived delivery queues for already-classified follow-up work, as long as commit and persistence policy still belong to `RuntimeCoordinator`
|
|
|
|
This state is ingress-local and must not become a substitute for committed runtime state.
|
|
|
|
### 4. WebSocket Session Management
|
|
|
|
The subsystem owns connection lifecycle for clients that observe runtime state, but it does not own the authoritative runtime model.
|
|
|
|
It is responsible for:
|
|
|
|
- accepting WebSocket upgrades
|
|
- tracking connected clients
|
|
- forwarding serialized state snapshots or health payloads produced elsewhere
|
|
- applying broadcast throttling or collapse policies when necessary
|
|
|
|
It is not responsible for deciding what the authoritative state is.
|
|
|
|
### 5. File-Watch and Reload Ingress
|
|
|
|
The subsystem should own the detection side of registry/file changes and reload requests.
|
|
|
|
It may:
|
|
|
|
- observe filesystem changes
|
|
- debounce bursts of related file events
|
|
- translate those changes into typed reload actions
|
|
|
|
It should not directly trigger render rebuilds or mutate shader/package state itself.
|
|
|
|
### 6. Service Health and Timing Reporting
|
|
|
|
`ControlServices` should emit operational signals into `HealthTelemetry`, including:
|
|
|
|
- OSC packet rate
|
|
- OSC decode failures
|
|
- queue depth / coalesced route count
|
|
- dropped or collapsed ingress events
|
|
- HTTP error counts
|
|
- WebSocket connection count
|
|
- reload request frequency
|
|
- file-watch failures
|
|
- service-thread startup/shutdown errors
|
|
|
|
## Explicit Non-Responsibilities
|
|
|
|
The following must stay outside `ControlServices` in the target design.
|
|
|
|
### Persistence Decisions
|
|
|
|
The subsystem may report that an input requested a state change, but it should not decide whether that change is persisted.
|
|
|
|
That belongs to `RuntimeCoordinator` and `RuntimeStore`.
|
|
|
|
### Render Snapshot Publication
|
|
|
|
`ControlServices` must not publish render-facing snapshots or poke render-local structures directly.
|
|
|
|
### Render-Local Overlay Ownership
|
|
|
|
Live OSC overlays, temporal state, shader feedback, and render-only transient state belong to `RenderEngine`.
|
|
|
|
`ControlServices` may ingest automation targets, but it should not own how those targets are applied inside the render domain.
|
|
|
|
### Hardware Timing or Playout Recovery
|
|
|
|
Device scheduling, queue headroom, and callback recovery belong to `VideoBackend`, not the control ingress path.
|
|
|
|
## Ingress Boundary Model
|
|
|
|
The clean boundary for `ControlServices` is:
|
|
|
|
- external transport in
|
|
- typed action/event out
|
|
|
|
That implies three layers inside the subsystem.
|
|
|
|
### Transport Adapters
|
|
|
|
These are protocol-facing components.
|
|
|
|
Examples:
|
|
|
|
- `OscIngress`
|
|
- `HttpControlIngress`
|
|
- `WebSocketSessionHost`
|
|
- `FileWatchIngress`
|
|
|
|
Responsibilities:
|
|
|
|
- socket/file watcher lifecycle
|
|
- protocol decoding
|
|
- request framing
|
|
- transport-level validation
|
|
- low-level authentication or origin checks later if added
|
|
|
|
### Normalization Layer
|
|
|
|
This layer translates decoded transport input into typed actions.
|
|
|
|
Responsibilities:
|
|
|
|
- route parsing
|
|
- payload type normalization
|
|
- parameter name/key resolution where that is purely syntactic
|
|
- conversion from transport-specific errors into typed ingress errors
|
|
|
|
This layer should not perform deep runtime mutation policy.
|
|
|
|
### Service Coordination Shell
|
|
|
|
This shell owns:
|
|
|
|
- startup/shutdown ordering for ingress services
|
|
- shared ingress-local queues
|
|
- service-thread lifecycle
|
|
- handing normalized actions to `RuntimeCoordinator`
|
|
- handing outbound snapshot payloads to WebSocket clients
|
|
|
|
This shell is the spiritual successor to the hosting part of current `RuntimeServices`, but with a much narrower responsibility set.
|
|
|
|
## Service Timing Concerns
|
|
|
|
`ControlServices` is the correct place to isolate transport-level timing concerns that should not leak into whole-app state policy.
|
|
|
|
### OSC Timing
|
|
|
|
Current behavior already points in the right direction:
|
|
|
|
- OSC receive is on its own thread in `OscServer`
|
|
- latest values are coalesced by route in `RuntimeServices`
|
|
- updates are applied once per render tick rather than per packet
|
|
|
|
Relevant code:
|
|
|
|
- [OscServer.cpp](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/control/OscServer.cpp:95)
|
|
- [RuntimeServices.cpp](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/control/RuntimeServices.cpp:65)
|
|
- [RuntimeServices.cpp](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/control/RuntimeServices.cpp:82)
|
|
|
|
Target rule:
|
|
|
|
- network receive and decode stay inside `ControlServices`
|
|
- coalescing policy stays inside `ControlServices`
|
|
- classification of the resulting action belongs to `RuntimeCoordinator`
|
|
- render-local application belongs to `RenderEngine`
|
|
|
|
This keeps high-rate ingress cheap without giving the service layer authority over render behavior or committed-state policy.
|
|
|
|
### HTTP / UI Timing
|
|
|
|
HTTP control requests are operator-facing and usually low-rate, but the UI can still generate bursts through slider drags or repeated parameter edits.
|
|
|
|
`ControlServices` should:
|
|
|
|
- normalize each request into a typed action
|
|
- allow collapse/throttle policies for purely observational outbound state pushes
|
|
- avoid synchronous full-state serialization on every ingress event where possible
|
|
|
|
It should not decide whether a request results in immediate, deferred, transient, or persisted mutation. That is a coordinator concern.
|
|
|
|
### WebSocket Broadcast Timing
|
|
|
|
Outbound state streaming is control-plane behavior, not core runtime ownership.
|
|
|
|
Current code already distinguishes immediate and requested broadcasts:
|
|
|
|
- [ControlServer.cpp](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/control/ControlServer.cpp:163)
|
|
- [ControlServer.cpp](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/control/ControlServer.cpp:170)
|
|
|
|
Target rule:
|
|
|
|
- `ControlServices` may own broadcast scheduling and collapse policy
|
|
- the source state payload should come from snapshot/telemetry producers, not from service-owned mutable state
|
|
|
|
### File-Watch Timing
|
|
|
|
Current file-watch and deferred OSC commit work run on a coarse poll loop:
|
|
|
|
- [RuntimeServices.cpp](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/control/RuntimeServices.cpp:194)
|
|
|
|
This is one of the cleaner migration opportunities in the whole app.
|
|
|
|
Target rule:
|
|
|
|
- file-watch detection belongs in `ControlServices`
|
|
- coarse polling should eventually be replaced with either event-driven watching or a narrower, typed background loop
|
|
- detected changes should be debounced and surfaced as typed reload-related actions
|
|
|
|
### Service Backpressure
|
|
|
|
`ControlServices` needs explicit backpressure rules for high-rate sources.
|
|
|
|
Recommended policies:
|
|
|
|
- coalesce latest-value automation by route
|
|
- bound per-service queues
|
|
- count and report dropped/coalesced events
|
|
- prefer collapsing observation work before collapsing operator mutations
|
|
- never let service queues become hidden durable state
|
|
|
|
## Interfaces
|
|
|
|
These are suggested target-facing interfaces, not final class signatures.
|
|
|
|
### Subsystem Shell
|
|
|
|
Possible top-level responsibilities:
|
|
|
|
- `Start(...)`
|
|
- `Stop()`
|
|
- `PublishStateSnapshot(...)`
|
|
- `PublishHealthSnapshot(...)`
|
|
- `DrainNormalizedActions(...)`
|
|
|
|
The shell should feel like a host for ingress adapters plus a normalization/buffering boundary.
|
|
|
|
### OSC Ingress
|
|
|
|
Possible responsibilities:
|
|
|
|
- `StartOscIngress(...)`
|
|
- `StopOscIngress()`
|
|
- `ConfigureOscBinding(...)`
|
|
- `EnqueueDecodedOscMessage(...)`
|
|
- `DrainCoalescedAutomationTargets(...)`
|
|
|
|
### HTTP / Web Control Ingress
|
|
|
|
Possible responsibilities:
|
|
|
|
- `StartHttpIngress(...)`
|
|
- `StopHttpIngress()`
|
|
- `HandleHttpRequest(...)`
|
|
- `HandleWebSocketUpgrade(...)`
|
|
- `QueueStateBroadcastRequest()`
|
|
|
|
### File-Watch Ingress
|
|
|
|
Possible responsibilities:
|
|
|
|
- `StartFileWatchIngress(...)`
|
|
- `StopFileWatchIngress()`
|
|
- `PollOrConsumeFileEvents(...)`
|
|
- `DrainReloadSignals(...)`
|
|
|
|
### Normalized Action Types
|
|
|
|
These should likely become shared event/action definitions in Phase 2, but `ControlServices` should be designed around them now.
|
|
|
|
Examples:
|
|
|
|
- `LayerAddRequested`
|
|
- `LayerRemovedRequested`
|
|
- `LayerReorderedRequested`
|
|
- `LayerBypassSetRequested`
|
|
- `LayerShaderSetRequested`
|
|
- `ParameterSetRequested`
|
|
- `LayerResetRequested`
|
|
- `StackPresetSaveRequested`
|
|
- `StackPresetLoadRequested`
|
|
- `ShaderReloadRequested`
|
|
- `ScreenshotRequested`
|
|
- `AutomationTargetReceived`
|
|
- `RegistryChangeDetected`
|
|
|
|
## Data Ownership Inside The Subsystem
|
|
|
|
`ControlServices` is allowed to own ingress-local ephemeral state.
|
|
|
|
Examples:
|
|
|
|
- connected WebSocket client list
|
|
- pending broadcast flag
|
|
- coalesced OSC route map
|
|
- outstanding decoded-but-undrained action queue
|
|
- file-watch debounce state
|
|
- transport error counters before publication to telemetry
|
|
|
|
It should not own:
|
|
|
|
- authoritative layer stack state
|
|
- committed parameter values
|
|
- render snapshots
|
|
- playout queue state
|
|
- shader feedback or render overlays
|
|
|
|
The rule is simple:
|
|
|
|
- if the state exists only to absorb or forward external input, it can live here
|
|
- if the state defines how the app should behave over time, it belongs elsewhere
|
|
|
|
## Outbound Boundaries
|
|
|
|
`ControlServices` talks outward in only a few approved directions.
|
|
|
|
### To `RuntimeCoordinator`
|
|
|
|
Primary outbound path.
|
|
|
|
It sends:
|
|
|
|
- normalized mutation requests
|
|
- automation targets
|
|
- reload requests
|
|
- stack preset requests
|
|
|
|
It does not send:
|
|
|
|
- transport-specific objects such as raw sockets or OSC packet structures
|
|
- render-facing state objects
|
|
|
|
### To `HealthTelemetry`
|
|
|
|
Observation-only relationship.
|
|
|
|
It sends:
|
|
|
|
- counters
|
|
- warnings
|
|
- timing samples
|
|
- service health transitions
|
|
|
|
It should not use `HealthTelemetry` as a hidden control path.
|
|
|
|
### From Snapshot / Telemetry Producers To Web Clients
|
|
|
|
`ControlServices` may deliver serialized outbound payloads to WebSocket clients, but the authoritative payload contents should be produced by the owning subsystems.
|
|
|
|
That means a later design may look like:
|
|
|
|
- `RuntimeSnapshotProvider` provides render-facing snapshot payloads or a runtime-state projection derived from those published snapshots
|
|
- `RuntimeCoordinator` or a later runtime-read-model helper provides control-plane runtime summaries when the UI needs more than raw render state
|
|
- `HealthTelemetry` provides health payloads
|
|
- `ControlServices` delivers them to connected observers
|
|
|
|
## Current Code Mapping
|
|
|
|
This section maps the current implementation onto the target subsystem.
|
|
|
|
### Current `RuntimeServices`
|
|
|
|
Should split into:
|
|
|
|
- `ControlServices` shell
|
|
- temporary compatibility adapter into `RuntimeCoordinator`
|
|
- removal of any direct runtime-state mutation responsibilities over time
|
|
|
|
Likely keep under `ControlServices`:
|
|
|
|
- service startup/shutdown
|
|
- OSC update coalescing
|
|
- Web control hosting shell
|
|
- file-watch ingress hosting
|
|
|
|
Should move out later:
|
|
|
|
- direct `RuntimeHost` polling dependency
|
|
- deferred OSC commit behavior as currently implemented through direct host mutation
|
|
- any remaining direct state-broadcast decisions tied to runtime internals
|
|
|
|
### Current `ControlServer`
|
|
|
|
Should become primarily:
|
|
|
|
- HTTP ingress adapter
|
|
- WebSocket session host
|
|
- static asset/doc host if that remains embedded
|
|
|
|
The callback table in current code:
|
|
|
|
- [ControlServer.h](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/control/ControlServer.h:18)
|
|
|
|
is a useful migration aid, but long-term it should evolve from callback-per-action toward typed action emission.
|
|
|
|
### Current `OscServer`
|
|
|
|
Should remain transport-focused.
|
|
|
|
Its clean long-term responsibilities are:
|
|
|
|
- UDP socket lifecycle
|
|
- OSC frame decode
|
|
- syntactic route extraction
|
|
- emitting decoded automation payloads into the `ControlServices` shell
|
|
|
|
It should not own any runtime state semantics beyond ingress decoding.
|
|
|
|
## Migration Plan
|
|
|
|
The safest migration is incremental.
|
|
|
|
### Step 1. Name The Boundary Explicitly
|
|
|
|
Create and use the `ControlServices` name in docs and future interfaces before moving all logic.
|
|
|
|
This document is part of that step.
|
|
|
|
### Step 2. Convert Callback Thinking Into Action Thinking
|
|
|
|
Without changing all runtime code at once, introduce typed action/event shapes for the major ingress paths.
|
|
|
|
The goal is for transports to emit actions, even if temporary adapters still call into existing code.
|
|
|
|
### Step 3. Extract Service Hosting From `OpenGLComposite`
|
|
|
|
`OpenGLComposite` currently owns `RuntimeServices` startup and consumption:
|
|
|
|
- [OpenGLComposite.cpp](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLComposite.cpp:312)
|
|
- [OpenGLComposite.cpp](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLComposite.cpp:723)
|
|
|
|
That should move toward a composition root or subsystem host arrangement where render is no longer the owner of control ingress.
|
|
|
|
### Step 4. Remove Direct `RuntimeHost` Dependency
|
|
|
|
Current polling and deferred OSC commit work directly against `RuntimeHost`:
|
|
|
|
- [RuntimeServices.cpp](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/control/RuntimeServices.cpp:194)
|
|
|
|
That should be replaced with coordinator-facing actions and later event-driven flows.
|
|
|
|
### Step 5. Split Out Observation Delivery
|
|
|
|
WebSocket outbound delivery can stay in `ControlServices`, but serialization ownership should move toward the owning subsystems so the service layer stops assembling authoritative state itself.
|
|
|
|
## Risks
|
|
|
|
### Risk 1. Recreating `RuntimeHost` Coupling Under A New Name
|
|
|
|
If `ControlServices` is allowed to keep direct knowledge of runtime mutation internals, it will become a renamed version of the same coupling problem.
|
|
|
|
Mitigation:
|
|
|
|
- keep the boundary strict
|
|
- route mutations through coordinator interfaces
|
|
- treat direct host calls as temporary compatibility only
|
|
|
|
### Risk 2. Service Queues Becoming Hidden State Authority
|
|
|
|
Latest-value OSC maps and reload debounce flags are appropriate here. Full committed runtime state is not.
|
|
|
|
Mitigation:
|
|
|
|
- define ingress-local versus authoritative state explicitly
|
|
- bound queues
|
|
- publish queue metrics into telemetry
|
|
|
|
### Risk 3. WebSocket Broadcast Path Reintroducing Heavy Synchronous Work
|
|
|
|
If `ControlServices` becomes the place where whole runtime state is rebuilt or serialized on every input, it will recreate timing stalls.
|
|
|
|
Mitigation:
|
|
|
|
- broadcast snapshots produced elsewhere
|
|
- collapse redundant outbound requests
|
|
- track serialization/broadcast timing in telemetry
|
|
|
|
### Risk 4. Polling Surviving Too Long As Architecture
|
|
|
|
Some polling may remain during migration, but it should not become the permanent contract.
|
|
|
|
Mitigation:
|
|
|
|
- isolate polling behind ingress interfaces
|
|
- make replacement with event-driven flows a planned Phase 2/3 outcome
|
|
|
|
## Open Questions
|
|
|
|
- Should the embedded static UI/docs hosting stay inside `ControlServices`, or move to a thinner app-shell concern while control APIs remain in `ControlServices`?
|
|
- Should outbound state for WebSocket clients be one combined payload or separate runtime and health channels?
|
|
- How much route/key resolution should happen in `ControlServices` versus `RuntimeCoordinator`?
|
|
- Should any deferred automation-settle delivery remain in `ControlServices`, or should all commit/settle policy move entirely into coordinator/render ownership once the live-state model is formalized?
|
|
- When file watching is modernized, should reload classification live entirely in `ControlServices`, or should it emit a lower-level `FilesChanged` event and let `RuntimeCoordinator` decide reload semantics?
|
|
- Will future non-OSC automation sources reuse the same `AutomationTargetReceived` path, or need source-specific typed actions for policy reasons?
|
|
|
|
## Short Version
|
|
|
|
`ControlServices` should become the app's clean ingress boundary:
|
|
|
|
- transport handling stays here
|
|
- input normalization stays here
|
|
- ingress-local buffering stays here
|
|
- mutation policy does not
|
|
- authoritative runtime state does not
|
|
- render-local transient state does not
|
|
|
|
If later phases keep that line sharp, the app gains a control layer that is fast, testable, and timing-aware without becoming another shared state authority.
|