Files
video-shader-toys/docs/subsystems/ControlServices.md
Aiden a530325fa1
Some checks failed
CI / React UI Build (push) Successful in 10s
CI / Native Windows Build And Tests (push) Successful in 2m42s
CI / Windows Release Package (push) Has been cancelled
Organisation
2026-05-11 19:31:06 +10:00

21 KiB

ControlServices Subsystem Design

This document expands the ControlServices subsystem described in 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:

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, with RuntimeStore and the later persistence writer carrying out durable writes when policy requests them.

Render Snapshot Publication

ControlServices must not publish render-facing snapshots or poke render-local structures directly.

Render-Local Overlay Ownership

Live OSC automation overlays belong to the live-state/render preparation boundary (RuntimeLiveState today). Temporal state, shader feedback, output staging, and other render-only transient state belong to RenderEngine.

ControlServices may ingest and coalesce automation targets, but it should not own how those targets are composed, committed, persisted, or 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:

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:

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:

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:

  • legacy direct runtime polling dependency
  • deferred OSC commit behavior that has since moved behind coordinator-facing outcomes
  • 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:

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:

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 Runtime Mutation Dependency

Previous polling and deferred OSC commit work directly against runtime storage:

That has been routed through coordinator-facing actions; later phases should replace the remaining polling shape with 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 any direct runtime mutation calls as migration-only compatibility

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.