English · Русский
A typed Python SDK for building MiraBox Stream Dock plugins
Build reusable actions for keys, touch panels, and dials
without hand-writing the Stream Dock WebSocket protocol.
Quick start · PyPI · Example plugin · Protocol · API · Development
mirabox-stream-dock-sdk provides the protocol, runtime, and browser-side tools
needed to build Python plugins for MiraBox Stream Dock. It validates launch
arguments and incoming messages, creates one typed action instance per visible
control, dispatches lifecycle events, and serializes commands back to the
Stream Dock application.
The SDK was originally developed as part of a Stream Dock plugin. It was later extracted into a standalone project so the protocol and runtime could be reused across plugins, tested independently, and evolved as a public package. The SDK will continue to be improved as it is used in real plugins and more Stream Dock behavior is verified.
Important
The project is currently in the 0.x series. It is ready for experimentation
and real plugin development, but public APIs may evolve between minor releases
before 1.0.
Note
This is an unofficial community project and is not affiliated with or endorsed
by MiraBox, HotSpot, or Elgato. The callback name
connectElgatoStreamDeckSocket is retained because Stream Dock uses it for
Property Inspector compatibility.
- Features
- How it works
- Requirements
- Installation
- New plugin to a validated bundle
- Quick start
- Property Inspector client
- Counter example plugin
- Protocol basis
- API overview
- Errors and unknown events
- Inbound event queue
- Outbound command bus
- Concurrency contract
- Logging
- Project structure
- Development
- Releasing
| Feature | What it provides | |
|---|---|---|
| 🧩 | Typed protocol | Dataclass models for registration, commands, and key, touch, dial, device, application, and settings events. |
| 🧭 | Precise validation | Malformed payloads report the event name and exact JSON field path that failed validation. |
| 🎛️ | Action runtime | One action instance per Stream Dock context, declarative UUID registration, and automatic lifecycle dispatch. |
| 🔌 | WebSocket transport | Registration, message parsing, command serialization, logging, and graceful shutdown. |
| 🗃️ | Typed settings | Pluggable codecs for action settings, global settings, and Property Inspector messages. |
| 🖥️ | Property Inspector | A versioned, dependency-free JavaScript client with connection state, events, settings helpers, and queued startup messages. |
| 🧰 | Plugin services | Start and stop plugin-owned background services in a predictable order. |
| 📦 | Distribution tooling | A CLI resource copier, PyInstaller example, package verification, CI, and Trusted Publishing workflow. |
| 🛡️ | Forward compatibility | Unknown but valid events can be preserved as UnknownStreamDockEvent instead of breaking the plugin. |
flowchart LR
App["MiraBox Stream Dock<br>Windows"] <-->|"WebSocket · JSON"| Boundary["Typed Stream Dock boundary"]
Boundary --> Runtime["StreamDockRuntime<br>keyed-serial dispatcher"]
Registry["ActionRegistry<br>UUID → Action class"] --> Runtime
Runtime --> Actions["Action instances<br>one per context"]
PI["Property Inspector<br>HTML / JavaScript"] <-->|"settings and messages"| App
Client["MiraBoxPropertyInspector<br>browser client"] --> PI
Stream Dock starts the packaged plugin executable with the WebSocket port,
plugin UUID, registration event, and application metadata. run_plugin_cli()
parses those arguments, while create_stream_dock_application() composes the
typed boundary and runtime that register the plugin and route incoming events.
- Python
3.11+; - MiraBox Stream Dock
2.10.179.426or newer (declared minimum); websocket-client>=1.8,<2(installed automatically);- Windows to run Stream Dock and package a standalone plugin with PyInstaller.
The SDK's Stream Dock integration has been manually verified with Stream Dock
3.10.203.0701.
The SDK itself and its test suite can be developed on Windows, Linux, or WSL.
The final .exe must be built on Windows because PyInstaller is not a
cross-compiler.
Install the released package from PyPI:
python -m pip install mirabox-stream-dock-sdkTo work on the SDK from source:
git clone https://github.com/Nekit678/MiraboxStreamDockSDK.git
cd MiraboxStreamDockSDK
python -m venv .venvWindows PowerShell
.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"Linux / WSL
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"With the SDK installed in an activated Python 3.11+ environment, use this
Windows PowerShell sequence. For these unreleased changes, first install the
current checkout with python -m pip install -e . from the SDK root.
mirabox-sdk init-plugin hello-plugin --uuid com.example.hello --name "Hello"
cd hello-plugin
python -m pip install -e ".[build]"
python -m unittest discover -s tests -v
python -m PyInstaller --clean --noconfirm build.spec
Copy-Item dist\Plugin.exe com.example.hello.sdPlugin\
mirabox-sdk validate-plugin com.example.hello.sdPlugin --registry dock_plugin.bootstrap:ACTION_REGISTRYEdit src/dock_plugin/bootstrap.py and rerun the harness test before building.
The generator supplies matching plugin/action UUIDs, manifest, PI client and
assets, package metadata, a Windows PyInstaller spec and a self-contained README.
The minimal action uses ApplicationContext directly. The destination must be
new; existing directories and files are never overwritten.
Copy the validated com.example.hello.sdPlugin directory into
%APPDATA%\HotSpot\StreamDock\plugins\, restart Stream Dock and add
Examples → Hello. Tests work on Linux/WSL too: install with
python -m pip install -e . and run the same unittest command; build the .exe
on Windows. The source bundle fails validation until the executable is copied.
Validation checks files and registry UUIDs; it does not run the binary.
For plugin-owned background work, see the complete
background service example. It uses
service_factories, session readiness, stop_signal, finite command waits and
join, error reporting and shutdown_outcome. Both examples use the current
public API and avoid private imports.
Define dependencies shared by your action instances, register each action UUID,
and return a configured StreamDockApplication from the application factory:
from __future__ import annotations
from dataclasses import dataclass
from mirabox_sdk import (
Action,
ActionRegistry,
JsonObject,
KeyDownEvent,
PluginLaunchArguments,
StreamDockApplication,
StreamDockSender,
WillAppearEvent,
create_stream_dock_application,
run_plugin_cli,
)
ACTION_UUID = "com.example.counter.increment"
@dataclass(frozen=True, slots=True)
class Dependencies:
stream_dock: StreamDockSender
registry: ActionRegistry[Dependencies] = ActionRegistry()
@registry.register(ACTION_UUID)
class CounterAction(Action[JsonObject, Dependencies]):
def _render(self) -> None:
count = self.settings.get("count", 0)
self.set_title(str(count if type(count) is int else 0))
def on_will_appear(self, _event: WillAppearEvent) -> None:
self._render()
def on_key_down(self, _event: KeyDownEvent) -> None:
count = self.settings.get("count", 0)
self.set_settings({"count": (count if type(count) is int else 0) + 1})
self._render()
def build_application(arguments: PluginLaunchArguments) -> StreamDockApplication:
return create_stream_dock_application(
arguments,
action_factory=registry,
action_dependencies_factory=lambda ctx: Dependencies(ctx.stream_dock),
)
if __name__ == "__main__":
raise SystemExit(run_plugin_cli(build_application))The exact same action UUID must appear in the plugin's manifest.json. Stream
Dock creates and removes action contexts through willAppear and
willDisappear; the runtime manages the corresponding Python instances.
ActionRegistry[Dependencies].register() checks that the action constructor
accepts the registry's dependency type and preserves the concrete action class,
including its own methods and settings type. Its return annotation is the
public ActionRegistration[Dependencies] protocol. Incompatible dependency
containers are reported by the type checker when the action is registered.
Override only the callbacks an action needs:
| Input or lifecycle | Action callback |
|---|---|
| Action becomes visible or disappears | on_will_appear, on_will_disappear |
| Key press or release | on_key_down, on_key_up |
| Touch panel tap | on_touch_tap |
| Dial press, release, or rotation | on_dial_down, on_dial_up, on_dial_rotate |
| Settings or title parameters change | on_did_receive_settings, on_title_parameters_did_change |
| Property Inspector opens, closes, or sends data | on_property_inspector_did_appear, on_property_inspector_did_disappear, on_send_to_plugin |
| Device, application, and wake-up notifications | on_device_did_connect, on_device_did_disconnect, on_application_did_launch, on_application_did_terminate, on_system_did_wake_up |
Action helper methods cover the common outbound commands: set_title(),
set_image(), set_state(), set_settings(), get_settings(), show_ok(),
show_alert(), open_url(), log_message(), and
send_to_property_inspector(). Display-only updates also have non-blocking
set_title_async(), set_image_async(), and set_state_async() variants.
Actions use JSON objects by default. To work with an application-specific type,
pass a matching JsonCodec to registry.register():
from dataclasses import dataclass
from mirabox_sdk import Action, FunctionalJsonCodec, JsonObject
@dataclass(frozen=True, slots=True)
class CounterSettings:
count: int
def decode_settings(value: JsonObject) -> CounterSettings:
count = value.get("count", 0)
if type(count) is not int:
raise ValueError("count must be an integer")
return CounterSettings(count)
COUNTER_SETTINGS_CODEC = FunctionalJsonCodec(
decoder=decode_settings,
encoder=lambda value: {"count": value.count},
)
@registry.register("com.example.counter.increment", settings_codec=COUNTER_SETTINGS_CODEC)
class CounterAction(Action[CounterSettings, Dependencies]):
passRegistration keeps the codec local to that UUID and registry and checks the
action's settings and dependency types, including when a custom constructor
accepts a wider settings argument. The decorator returns the original class;
the registry creates an internal subclass with the registered codec, preserving
isinstance(action, YourAction) and custom constructors and classmethod decoders.
Read the registered codec from action.settings_codec or
action.get_settings_codec(); the original class retains its default codec.
Reusing the class in another registry or for another UUID does not change
existing instances. Typed actions must pass settings_codec even
when their class or a parent class already defines it; registration without
that argument accepts actions with JsonObject settings. Existing typed
actions should move their codec assignment into the registration call.
The codec boundary verifies that encoded values are valid JSON. Decode errors are wrapped with the relevant event name and settings path.
application.global_settings is the one runtime-owned facade shared by the
application and context-aware action-dependency and service factories. Use
update() when several in-memory changes belong to one logical operation. The
callback works on an isolated draft; an exception or invalid JSON result rolls
back the complete update:
def append_items(settings: JsonObject) -> None:
items = settings.get("items")
if not isinstance(items, list):
raise ValueError("items must be a list")
items.extend(values)
application.global_settings.update(append_items)After the callback succeeds, the transaction validates the complete draft and
persists it with one setGlobalSettings command. Callback, validation, and send
failures leave the previous local state unchanged. snapshot() returns an
isolated copy, so changing it never changes the runtime state. Use set() or
set_typed() for complete replacements. The facade exposes no mutable
settings property; read the current value through snapshot().
Readers continue to see the last committed snapshot while a mutation or send is
in progress. Writes and incoming global-settings replacements stay serialized.
For callbacks that should return before transport completion, use
update_async(), set_async(), or set_typed_async(). Mutation, encoding and
validation run on the calling thread; the returned CommandFuture completes
after local commit or rollback. Only one transaction can be in progress:
overlapping async calls raise GlobalSettingsBusyError before running their
mutator or codec. Retain a rejected operation for an application service to retry
after the active transaction finishes. Synchronous writes wait and then use the
latest committed state.
import logging
from mirabox_sdk import CommandFuture
logger = logging.getLogger(__name__)
def observe_command(completion: CommandFuture) -> None:
error = completion.exception(timeout=0)
if error is not None:
logger.error("Outbound command failed; exception_type=%s", type(error).__name__)
completion = application.global_settings.update_async(append_items)
completion.add_done_callback(observe_command)Preparation and queue-acceptance errors still propagate from the async call.
completion.result(timeout=...) bounds only the caller's wait: an accepted
transaction can still send and commit later. Expiring that wait does not roll
back or cancel the command. Transport failure preserves the previous local
state; successful transport completion is not a device persistence acknowledgement.
Copy the JavaScript client shipped with the installed SDK into the plugin bundle:
mirabox-sdk copy-property-inspector \
com.example.counter.sdPlugin/property-inspectorThe command refuses to overwrite a different copy by default. Pass --force
when intentionally updating the bundled client.
Load it before the action-specific script:
<script src="mirabox-sdk.js"></script>
<script src="counter.js"></script>Stream Dock invokes the compatibility callback automatically. The action script
uses the shared client through window.MiraBoxPropertyInspector:
const client = window.MiraBoxPropertyInspector;
client.on("connected", ({ settings }) => {
console.log("Current settings", settings);
client.sendToPlugin({ event: "refresh" });
client.updateSettings({ mode: "toggle" });
});
client.on("didReceiveSettings", ({ payload }) => {
console.log("Updated settings", payload.settings);
});The client exposes on(), off(), send(), sendToPlugin(), setSettings(),
updateSettings(), and getSettings(), plus connection and registration state.
Messages sent while the WebSocket is connecting are queued until it opens.
Start application work in connected: calls before the host invokes the
connection callback or after the socket starts closing throw an Error.
Send methods return true for an immediate send and false only for an
accepted queued message. Invalid JSON data (including cycles and BigInt)
throws before acceptance; immediate WebSocket.send() errors propagate.
Frames are limited to 8 MiB of UTF-8 JSON, including their envelope. While
connecting, the FIFO accepts at most 1,024 messages and 16 MiB of wire bytes.
For an open socket, every send (including registration and each queued send)
checks that WebSocket.bufferedAmount plus the frame fits the same 16 MiB limit.
An immediate send that exceeds a limit throws RangeError before acceptance
and leaves local settings unchanged. A queued send that no longer fits emits
sendError and is not retried. Accepted messages retain their order and are not
replaced automatically. client.queueMetrics reports limits, local queue
current/peak depth and bytes, active socket bufferedBytes, and
rejectedFull/rejectedOversized. Flush and close release the local queue budget;
the socket buffer drains as the browser transmits data. These wire byte budgets
do not measure total browser memory. Oversized incoming frames emit
protocolError before JSON parsing.
Queued messages own serialized JSON snapshots. Deferred send failures emit
sendError with { message, error } for each unsent message, including when
the socket closes before flushing. A failed message does not prevent later
messages from being attempted; failed registration closes the socket without
emitting connected. Sends are not retried automatically.
A repeated connection callback closes the previous socket and reports its
unsent queued messages through sendError. Old socket events cannot change
the new session's settings, queue, or lifecycle events. Invalid callback data
leaves the current connection intact.
settings returns a deep JSON copy. Local settings change only when a send is
accepted and remain optimistic until didReceiveSettings refreshes them;
neither acceptance nor connected confirms host persistence. Deferred failures
do not roll back accepted local settings. Listen for sendError and request a
fresh snapshot with getSettings() while connected if reconciliation is needed.
Validate the assembled bundle before installing it:
mirabox-sdk validate-plugin com.example.counter.sdPlugin \
--registry counter_plugin.bootstrap:ACTION_REGISTRYThe registry module must be importable in the current Python environment and
load every action registration without starting the plugin. --registry
imports that module and compares the UUID sets in both directions. Without it,
the command checks manifest UUID syntax and duplicates and reports that the
registry comparison was skipped.
Validation checks required manifest fields and known optional field types,
CodePath/platform variants, icons, state images, Property Inspector pages and
their local script/link/image references. Files must stay inside the bundle.
Internal directory symlinks are supported; each resolved directory is scanned
once, including when links form cycles. Symlinks outside the bundle are errors
and their targets are not inspected. Unresolvable links and directory read
errors are reported as diagnostics.
Every bundled mirabox-sdk.js must match the installed SDK byte for byte;
refresh stale or modified copies with copy-property-inspector --force.
Errors include a field or file path and return exit code 1; success returns
0. The Python equivalent is
validate_plugin(bundle_path, action_uuids=registry.action_uuids), exported from
mirabox_sdk, which returns a tuple of diagnostics (empty on success).
Unknown manifest fields are accepted. Validation does not run the executable,
check image dimensions, fetch remote resources, resolve dynamic JavaScript
imports or prove compatibility with Stream Dock. Validate after packaging:
the Counter source bundle intentionally lacks CounterPlugin.exe.
examples/counter_plugin
is a complete plugin rather
than an isolated code fragment. It includes:
- a package with a registered counter action;
- a Property Inspector that can reset the counter;
- a valid
.sdPluginbundle and manifest; - SVG assets and a PyInstaller specification;
- tests for the plugin behavior.
Build its executable on Windows:
python -m pip install pyinstaller
python -m PyInstaller --clean --noconfirm examples/counter_plugin/build.spec
Copy-Item dist\CounterPlugin.exe `
examples\counter_plugin\com.example.counter.sdPlugin\Copy the resulting com.example.counter.sdPlugin directory to
%APPDATA%\HotSpot\StreamDock\plugins\ and restart Stream Dock. See the
example guide
for a source-run command and
the complete packaging flow.
This package is an independent, typed Python implementation of the WebSocket / JSON plugin API published by MiraBox. The primary upstream sources are:
- the official StreamDock Plugin SDK repository, including its Python template;
- the official registration procedure, received events, and events sent reference;
- the official
manifest.jsonreference and Property Inspector guide; - the upstream template overview on DeepWiki for secondary, generated explanations of the repository;
- the Space Platform for publishing completed Stream Dock plugins.
The local protocol map connects each supported wire event and command to its Python model or helper and calls out behavior verified in Stream Dock but not currently listed in the upstream event reference. When the published documentation and observed runtime behavior differ, tests record the behavior implemented by this SDK.
| Area | Public API |
|---|---|
| Runtime | StreamDockApplication, ApplicationRuntime, ApplicationContext, ApplicationService, SessionReadiness, create_stream_dock_application, RuntimeDispatcherConfig, runtime metrics and ports |
| Actions | Action, ActionRegistry, StreamDockSender |
| Launch and registration | PluginLaunchArguments, registration dataclasses, parse_plugin_cli_arguments, run_plugin_cli |
| Input events | Typed immutable event models and InboundOverflowPolicy |
| Output commands | Registration, settings, title, image, state, feedback, URL, log, and Property Inspector command models; ValidatedWireMessage |
| Application data | JsonCodec, FunctionalJsonCodec, JsonObjectCodec, ValidatedJsonObject, OwnedJsonPayload, typed encode/decode helpers |
| Resources and bundles | copy_property_inspector_client, property_inspector_client_bytes, validate_plugin, mirabox-sdk init-plugin / copying / validation CLI |
| Parsing | parse_stream_dock_event, parse_registration_info, typed protocol errors |
| Logging | configure_logging with isolated console, file, and disable controls |
The supported public surface is exported from mirabox_sdk. Objects from
individual modules should be treated as implementation details unless they are
also exported there. The testing helpers are exported separately from
mirabox_sdk.testing.
Supported imports are mirabox_sdk and, for the documented runtime namespace,
mirabox_sdk.runtime; tests can use mirabox_sdk.testing. In the breaking
0.5.0 release, the unsupported direct legacy modules (connection, inbound, outbound, plugin, and stores) and
the temporary mirabox_sdk._next namespace were removed from distributions.
ApplicationRuntime is the complete contract used by
StreamDockApplication.runtime; RuntimeLifecycle is deliberately narrower
and cannot be passed to StreamDockApplication. Application composition accepts
ActionRegistry or the public DependencyAwareActionRegistry protocol. Scheduler
selection uses RuntimeDispatcherConfig; transport and scheduler adapters and the
concrete runtime are internal implementation details.
The unreleased API removes the ActionFactory, HandlerSchedulerFactory,
WebSocketConnectorFactory, StreamDockRuntime, and
StreamDockRuntimeLifecycleError exports, and the connector_factory /
scheduler_factory application parameters. Replace test connectors with
mirabox_sdk.testing.StreamDockHarness, and use ApplicationRuntime for runtime
facades. Imports from mirabox_sdk._internal remain unsupported.
All metric snapshot types, including CommandWriterMetrics, EventReaderMetrics,
InboundEventQueueMetrics, OutboundCommandQueueMetrics, TransportQueueMetrics,
and WebSocketConnectorMetrics, are importable from mirabox_sdk and
mirabox_sdk.runtime.
StreamDockHarness replaces socket I/O with in-memory frames while exercising
the production registration, settings, codecs, queues and action dispatcher.
It accepts inbound_overflow_policy, coalesce_dial_rotations and
coalesce_commands with the same behavior and defaults as
create_stream_dock_application(): InboundOverflowPolicy.DROP_NEWEST, False
and False, respectively. Use these options with queue_config to test
coalescing and overflow behavior.
Pass the same launch arguments and factories used by your application:
from mirabox_sdk import (
Action,
ActionRegistry,
ApplicationContext,
JsonObject,
PluginLaunchArguments,
WillAppearEvent,
)
from mirabox_sdk.testing import StreamDockHarness
registry = ActionRegistry[ApplicationContext]()
@registry.register("com.example.test.action")
class TestAction(Action[JsonObject, ApplicationContext]):
def on_will_appear(self, event: WillAppearEvent) -> None:
self.set_title("ready")
def test_application(arguments: PluginLaunchArguments) -> None:
with StreamDockHarness(
arguments,
action_factory=registry,
action_dependencies_factory=lambda ctx: ctx,
) as harness:
harness.assert_registration()
harness.send_event(
"willAppear",
action="com.example.test.action",
context="button",
device="device",
payload={
"settings": {},
"coordinates": {"column": 0, "row": 0},
"isInMultiAction": False,
"controller": "Keypad",
},
)
harness.wait_for_events(1)
harness.assert_command(
"setTitle", context="button", payload={"title": "ready", "target": 0}
)Context entry starts the application, connects, and waits for registration and
the initial global-settings request. It does not wait for a settings response or
for background work started by Plugin.on_ready(). wait_for_events(n) waits
for n acknowledged events since startup. Context exit joins the harness
threads and reports runtime/service failures; plugin callback failures retain
the runtime's normal isolation behavior.
Use start(connect=False), connect(), and wait_ready() for pre-ready tests.
send_json() accepts JSON objects or raw text, including malformed input;
receive() consumes the next outbound message, while messages and frames
retain the complete output history. Waits and stop() have finite timeouts;
a timeout reports incomplete work and cannot interrupt user callback code.
For isolated action tests, inject FakeStreamDockSender from
mirabox_sdk.testing as the dependency's stream_dock. Its messages property
returns isolated wire snapshots; send_async() returns a completed public
CommandFuture, including serialization failures. See the
Counter tests for both
unit and application examples.
Extend the quick-start factory with ApplicationService implementations for
repositories, HTTP clients, background workers, and other resources required
by action callbacks:
from dataclasses import dataclass
from mirabox_sdk import (
ApplicationContext,
GlobalSettings,
LogMessageCommand,
PluginLaunchArguments,
StreamDockApplication,
StreamDockSender,
create_stream_dock_application,
)
class Repository:
def start(self) -> None:
...
def stop(self) -> None:
...
@dataclass(frozen=True, slots=True)
class Dependencies:
stream_dock: StreamDockSender
global_settings: GlobalSettings
def build_dependencies(context: ApplicationContext) -> Dependencies:
return Dependencies(
stream_dock=context.stream_dock,
global_settings=context.global_settings,
)
def build_repository(_context: ApplicationContext) -> Repository:
return Repository()
def build_application(arguments: PluginLaunchArguments) -> StreamDockApplication:
return create_stream_dock_application(
arguments,
action_factory=registry,
action_dependencies_factory=build_dependencies,
service_factories=(build_repository,),
)ApplicationContext is immutable and shared by all plugin, dependency, and service
factories. It provides the same stream_dock, global_settings,
session_readiness, and stop_signal objects to each collaborator, without mutable
wiring or internal imports.
action_dependencies_factory always receives ApplicationContext, regardless
of parameter names or annotations. Wrap an existing sender-only factory as
action_dependencies_factory=lambda ctx: old_factory(ctx.stream_dock).
For migration, legacy_action_dependencies_factory=old_factory still passes
only StreamDockSender and emits DeprecationWarning. The two factory
parameters are mutually exclusive.
Services start in declaration order before the WebSocket runtime connects and
stop in reverse order after it finishes. If startup fails, only services that
started successfully are stopped. Cleanup always attempts every started
service; a primary startup or runtime failure is preserved. stop() may still
be called from an action callback. Service cleanup runs on an application-owned
cleanup worker after runtime resources are released; after a shutdown timeout it
can continue after run() returns. Workers should observe
context.stop_signal.requested or context.stop_signal.wait(timeout) to exit
cooperatively before resources are closed.
ApplicationService.start() is a process-start phase only. The outbound writer
is not running there, so services must not call StreamDockSender, synchronous
global-settings setters, or block on context.session_readiness.wait(). Those
command calls fail immediately with OutboundCommandBusNotReadyError. A service
that needs Stream Dock I/O should start its own worker, have that worker wait on
context.session_readiness, and send only when wait() returns True:
def run_session_work(context: ApplicationContext) -> None:
if context.session_readiness.wait():
context.stream_dock.send(LogMessageCommand("session is ready"))The readiness signal opens after the transport connects and the registration
and initial global-settings-request commands complete. It does not wait for the
first global-settings response; use context.global_settings.loaded for that
separate state. If session initialization fails or ends before readiness,
wait() returns False; inspect failure to distinguish an initialization
error from normal closure.
Subclass Plugin to observe plugin-wide broadcast events independently of
active actions. Pass plugin_factory=MyPlugin to
create_stream_dock_application() to receive the same ApplicationContext
as action-dependency and service factories:
from mirabox_sdk import ApplicationContext, LogMessageCommand, Plugin, SystemDidWakeUpEvent
class MyPlugin(Plugin):
def __init__(self, context: ApplicationContext) -> None:
self.context = context
def on_ready(self) -> None:
self.context.stream_dock.send(LogMessageCommand("session is ready"))
def on_system_did_wake_up(self, event: SystemDidWakeUpEvent) -> None:
refresh_shared_state()An already-constructed instance is still accepted as plugin=existing_plugin.
plugin, plugin_factory, and legacy plugin_hooks are mutually exclusive.
The factory runs once during composition, before services or the runtime start;
it must return a Plugin and must not send commands or wait for readiness.
Construction errors close the boundary and propagate to the caller.
on_ready() runs once after registration and the initial global-settings request
complete, before any inbound protocol callbacks. It can send commands and use
the canonical global-settings facade without waiting or creating a service.
It does not wait for the first settings response. Return promptly and start
an owned worker for ongoing work. Override on_stop() to signal and join that
worker: the runtime calls it once after releasing actions and before stopping
application services, including when on_ready() raised after partial startup.
The transport is already closed at that point. Neither callback runs if the
session never reaches readiness. If a callback shutdown wait expires, action
cleanup, on_stop() and service cleanup are deferred until callbacks finish.
Resources remain allocated while work is unfinished.
Session callback exceptions are logged with the callback name and exception
type, without exception messages, and isolated from protocol delivery and
cleanup. on_stop() failures also appear in shutdown_outcome.cleanup_failures.
They do not replace a primary runtime failure. Routing metrics and
dispatch results describe wire-event callbacks only.
The runtime invokes the plugin callback first, then the stable snapshot of
active action callbacks. Global-settings state is updated before either
callback and each recipient receives an isolated settings event. A plugin
wire-event callback failure is logged and reported in runtime metrics/result
policy, but does not prevent active actions from receiving the event. PluginHooks remains
supported for its legacy on_unhandled_event()-only contract.
| Exception | Meaning |
|---|---|
InvalidPluginLaunchArgumentsError |
Stream Dock did not provide valid executable arguments. |
InvalidRegistrationInfoError |
The registration metadata JSON has an invalid field. |
MalformedEventError / InvalidFieldError |
A known event is malformed; the error includes its JSON path. |
UnsupportedEventError |
An unknown event was parsed with allow_unknown=False. |
JsonCodecDecodeError |
Plugin-owned settings or messages could not be decoded. |
JsonCodecEncodeError |
A codec produced a value that cannot be sent as JSON. |
OutboundQueueFullError |
The bounded outbound command queue is full. |
OutboundCommandBusNotReadyError |
A command was submitted before the outbound writer started. |
OutboundCommandBusClosedError |
A command was submitted after outbound shutdown began. |
By default, parse_stream_dock_event() preserves an unknown but structurally
valid envelope as UnknownStreamDockEvent. This lets the SDK tolerate protocol
extensions while known events remain strictly validated. The runtime delivers
each preserved event once to the configured Plugin or legacy PluginHooks
object. Unknown envelopes are not broadcast to actions because their routing
semantics are not known yet.
Protocol parsing metadata and runtime routing metadata are maintained in separate validated internal registries. The public API exposes typed event models rather than registry implementation details.
The typed boundary parses frames outside application callbacks and puts valid
events into a bounded queue. A keyed-serial worker pool invokes action
callbacks: one action context remains strictly ordered, while different
contexts can make progress concurrently. willAppear, willDisappear,
broadcast, and unknown events are exclusive ordering barriers; each waits for
earlier context callbacks and completes before later callbacks start. On normal
shutdown, the queue drains before StreamDockApplication.run() returns and
before the runtime releases actions.
The default runtime keeps excess events for a busy context in this bounded
source queue and admits other contexts from the segment before the next barrier.
With pending limit P (scheduler_pending_limit, default 64) and W workers,
each context may hold at most max(1, P // W) pending plus running events
without a global barrier.
The total scheduler pending limit still applies. No intermediate event buffer
is added: source, pending, running, and one pump handoff retain at most
inbound_event_limit + P + W + 1 events. A different context already in the
source can therefore reach an idle worker despite a hot-context burst. Global
barriers and backpressure before an event enters a full source still apply.
The pool defaults to four workers and the queue defaults to 1,024 events.
Lifecycle, settings, input, broadcast, unknown, and every other event except
dialRotate are lossless by default. dialRotate is explicitly coalescable
and may be discarded on overflow. Configure worker concurrency, the limit, and
overflow behavior for discardable events when constructing the application:
from mirabox_sdk import (
InboundOverflowPolicy,
RuntimeDispatcherConfig,
StreamDockQueueConfig,
create_stream_dock_application,
)
application = create_stream_dock_application(
arguments,
action_factory=registry,
action_dependencies_factory=build_dependencies,
queue_config=StreamDockQueueConfig(
raw_inbound_limit=512,
inbound_event_limit=512,
outbound_command_limit=512,
raw_outbound_limit=512,
session_event_limit=16,
),
runtime_config=RuntimeDispatcherConfig(worker_count=4),
inbound_overflow_policy=InboundOverflowPolicy.DROP_OLDEST,
coalesce_dial_rotations=True,
)DROP_NEWEST (the default) discards the newest eligible dialRotate;
DROP_OLDEST discards the oldest eligible rotation. Neither policy may evict a
lossless event. If the queue contains only lossless events, another lossless
event applies backpressure to the WebSocket reader until the dispatcher frees
space; an incoming rotation is discarded instead. This keeps memory bounded
without allowing overflow to corrupt runtime state.
Rotation coalescing is opt-in: compatible pending dialRotate events for the
same context and pressed state are combined by summing ticks; an intervening
event for that context, or any broadcast/unknown event, prevents coalescing.
Read application.metrics() for immutable queue, event-pump, scheduler, route,
action, session, and transport snapshots. RuntimeDispatcherConfig.shutdown_timeout
sets one total shutdown waiting budget (five seconds by default), shared by
boundary, runtime and service cleanup. Each stage receives the lesser of its
own timeout and the remaining budget. Set the total timeout to None only
when an unbounded overall wait is intended.
callback_drain_timeout limits the scheduler's shutdown drain wait. The old
callback_timeout name remains supported with a DeprecationWarning; the two
names are mutually exclusive. Neither setting monitors or interrupts callbacks
during normal operation. Bound external I/O separately.
To investigate a blocked barrier, inspect application.metrics().routing.active_callbacks.
Each immutable ActiveCallbackMetrics identifies the event, callback method,
action context (or None for a plugin handler) and current elapsed_seconds.
Broadcast recipients are tracked individually, as are settings replacement,
action creation and appearance rollback cleanup. The scheduler exposes
pending_barrier_event_name, current_barrier_wait_seconds and
peak_barrier_wait_seconds. Barrier wait starts at scheduler admission and ends
when dispatch starts or the pending barrier is discarded; it includes earlier
queued work. A running broadcast is shown through the active handler metrics.
Snapshots remain available while a handler is blocked. They contain no payloads.
RuntimeDispatcherConfig.slow_callback_threshold defaults to one second; set a
positive duration or None to disable warnings. After a handler finishes, the
runtime logs its event, method, context and duration if it exceeded the threshold,
including failed handlers. routing.slow_callbacks counts these completions and
routing.peak_callback_seconds records the longest completed invocation.
Warnings run after completion; these diagnostics do not interrupt a handler or
skip a barrier. callback_timeouts continues to count shutdown waits only.
ApplicationContext.stop_signal is set before shutdown starts closing resources,
on local stop, remote disconnect or fatal failure. Background work can use its
requested property or wait(timeout) method for cooperative cancellation.
Python cannot safely stop an arbitrary running thread. After a timeout the
caller can return while daemon work continues; action release, plugin stop and
service stop remain ordered after all callbacks and boundary workers finish.
Slow cleanup also continues in its owned daemon worker, without releasing
resources needed by earlier unfinished cleanup. Deferred cleanup cannot be
guaranteed if the process exits first.
application.shutdown_outcome is None before shutdown and otherwise returns
an immutable ShutdownOutcome snapshot. complete, workers_stopped,
unfinished_callbacks and pending_cleanup distinguish finished work from
work still running. cleanup_failures contains ShutdownFailure(stage, error)
with original exceptions; timed_out_stages, callback_timeouts,
discarded_events and discarded_commands retain shutdown diagnostics. Event
counts include typed-queue and scheduler discards; command counts include
command-queue and writer discards. Read a new snapshot to observe deferred
cleanup; old snapshots never change. successful requires completion without
failures or timeouts. The first fatal runtime failure remains primary, with
cleanup stages attached as exception notes. SystemExit and other unexpected
worker exits are fatal errors retaining the original exception in __cause__;
the owned event still reaches terminal acknowledgement.
Cleanup errors retain the existing exception policy for run()/stop();
service-stop errors completed within the waiting budget still propagate when
there is no primary failure. run_plugin_cli() returns 1 for an unsuccessful
shutdown outcome, including unfinished workers or cleanup failures.
Every StreamDockApplication owns one dedicated outbound writer.
Calling send() puts the typed command into a bounded FIFO queue; only that
writer validates and serializes the command, emits its protocol log, and calls
the WebSocket transport. Concurrent plugin threads therefore cannot interleave
frames. send() waits for its command's result, so serialization and transport
errors still reach the caller and state-update helpers retain their rollback
behavior.
send_async() performs the same queue acceptance but returns a
CommandFuture before serialization or WebSocket I/O. Queue-full, pre-start,
and shutdown rejections are raised immediately. Observe eventual failures with
future.add_done_callback(observe_command) or result() in application-owned
background work. Completion callbacks run on the completing thread, or immediately
on the registering thread if already done; keep them short. Synchronous command
sends (including Action.show_ok() and synchronous settings writes) and waits
for unfinished commands inside a completion callback raise
OutboundCommandReentrancyError. Synchronous sends are rejected before queue
acceptance or settings mutation. This rule also applies to late callbacks and
FakeStreamDockSender, so tests cannot hide a sender deadlock. Use async helpers
or sender.send_async(ShowOkCommand(action.context)) for follow-up commands and
return without waiting. Completed results and polls with timeout=0 remain
available. Before run() has
started the command writer, both send() and send_async() raise
OutboundCommandBusNotReadyError and do not enqueue a command. For
high-frequency display rendering, Action.set_image_async(),
set_title_async(), and set_state_async() avoid holding an inbound callback
while the writer is slow. Action.set_settings_async() and global settings
offer async persistence with commit/rollback. An action's local settings change
only after successful sending, before its persistence future completes.
Overlapping async action writes raise ActionSettingsBusyError before encoding;
synchronous writes and incoming settings wait for the pending write to finish.
Action.set_settings() remains synchronous and accepts a deadline.
Run slow external I/O in an application service and give dependency clients
finite operation deadlines. A command-future wait timeout does not impose a
transport deadline.
For callbacks that render frequently, submit commands with a finite deadline and observe every future, as the generated plugin does:
import logging
from time import monotonic
from mirabox_sdk import Action, ApplicationContext, CommandFuture, JsonObject, KeyDownEvent
logger = logging.getLogger(__name__)
def observe_display(completion: CommandFuture) -> None:
error = completion.exception(timeout=0)
if error is not None:
logger.error("Display command failed; exception_type=%s", type(error).__name__)
class ResponsiveAction(Action[JsonObject, ApplicationContext]):
def on_key_down(self, event: KeyDownEvent) -> None:
deadline = monotonic() + 1.0
self.set_title_async("Pressed", deadline=deadline).add_done_callback(observe_display)
self.show_ok_async(deadline=deadline).add_done_callback(observe_display)Queue-acceptance errors propagate from the helper to runtime callback diagnostics;
the observer handles eventual command failures without waiting. Commands from
this callback enter FIFO order, while other contexts may interleave. FIFO does
not make the second command conditional on the first succeeding: for that
requirement, check the first future's error and submit the follow-up asynchronously
from its completion observer. A callback or barrier can finish before its commands.
Keep completion observers short and use a service for slow external I/O; see
examples/background_service for readiness,
cooperative cancellation and bounded waits. Async does not speed up the sender.
future.result(timeout=...) re-raises the original command failure, including
a transport TimeoutError. Only an expired wait raises
TimeoutError("Outbound command did not complete before the timeout"); it does
not cancel the command, and a later call can retrieve its terminal result.
Set deadline=time.monotonic() + seconds on send() or send_async() to bound
an accepted command across the typed queue, serialization, raw-queue backpressure,
connection wait and WebSocket send. All synchronous and async action command
helpers, and all global settings write methods accept the same keyword.
None preserves unbounded completion. Invalid timestamps are rejected immediately.
Expiry completes the future with OutboundCommandDeadlineExceededError, a
TimeoutError subclass. Queued commands are removed and their capacity released;
they cannot be sent later. A shared deadline worker handles accepted commands,
including when the writer is blocked. Python encoding work may continue after
expiry, but its output is discarded. Caller-side codecs and mutation callbacks
must finish before an async persistence helper returns and cannot be interrupted.
Synchronous settings deadlines also cover waiting for an earlier transaction.
from time import monotonic
completion = action.set_settings_async(next_settings, deadline=monotonic() + 0.5)
completion.add_done_callback(observe_command)future.cancel() returns True only if it cancels the command before transport
send starts. Cancellation completes it with OutboundCommandCancelledError,
sets cancelled() and rolls back pending local settings. Once sending starts,
explicit cancellation returns False. Deadline expiry during a send aborts the
socket and ends the session, since a partial WebSocket write cannot be safely
reused. Delivery to the host is uncertain at this point; local settings remain
unchanged and the SDK does not retry. Other pending commands fail on disconnect.
Persistence futures delegate cancellation to their accepted command.
Commands with deadlines are not coalesced, keeping their budgets independent.
Cancelling any handle of a coalesced command without a deadline cancels the
shared physical command and completes every associated handle with cancellation.
The outbound queue holds 1,024 waiting commands by default. It never silently
drops a command when full: send() and send_async() raise
OutboundQueueFullError. Configure its capacity together with the other
boundary limits:
from mirabox_sdk import StreamDockQueueConfig, create_stream_dock_application
application = create_stream_dock_application(
arguments,
action_factory=registry,
action_dependencies_factory=build_dependencies,
queue_config=StreamDockQueueConfig(
raw_inbound_limit=512,
inbound_event_limit=512,
outbound_command_limit=512,
raw_outbound_limit=512,
session_event_limit=16,
),
coalesce_commands=True,
)Coalescing is opt-in. Compatible adjacent pending setState, setTitle,
setImage, setSettings, or setGlobalSettings commands for the same
semantic target are replaced by their newest value. Commands of another type or
target are ordering barriers. All callers whose commands were combined receive
distinct CommandFuture handles backed by the queued command's single
completion state, and therefore observe the same final write result. The queue
retains at most one completion state per physical entry.
Raw frame queues default to 16 MiB of UTF-8 wire bytes each, with an 8 MiB
limit per frame. Configure StreamDockQueueConfig.max_message_bytes,
raw_inbound_byte_limit, inbound_event_byte_limit,
outbound_command_byte_limit, and raw_outbound_byte_limit using positive
integers. The typed queue byte limits also default to 16 MiB each. They estimate
retained Python DTO/JSON bytes with sys.getsizeof(), including copy-on-write
backing storage, without serializing on the submitting thread.
Outbound submissions that exceed either capacity raise OutboundQueueFullError
synchronously. Raw outbound frame rejection completes the command future with
an error. An item larger than its queue's entire byte budget is rejected
immediately. Inbound byte pressure follows the existing backpressure/rotation
drop policy. Coalescing must fit the byte budget before changing an entry.
Queue metrics include byte_limit, current_bytes, peak_bytes, and
rejected_oversized; receiving or discarding an entry releases its bytes.
The keyed scheduler also limits pending retained bytes to 16 MiB; configure
RuntimeDispatcherConfig.scheduler_pending_byte_limit alongside the inbound
budget when increasing limits. Scheduler metrics expose pending_byte_limit,
current_pending_bytes, peak_pending_bytes, and rejected_oversized.
An event exceeding the entire scheduler budget receives a terminal error;
the application reports it as a runtime failure. Sequential dispatch has no
pending event buffer.
The built-in typed queue calculates an event's retained size before acquiring its admission lock and transfers that size through selection and keyed admission. Sizes belong to individual submissions: resubmission measures the current payload, and coalescing recalculates the replacement under the queue lock. Custom adapters without accounting transfer continue to use the ordinary event methods.
These budgets cover queued content. Shared objects are counted separately in different entries; queue/completion bookkeeping, active workers, allocator overhead, and opaque native allocations are outside the budgets, so they do not define a process RSS limit. Keep submitted DTOs unchanged until processing completes. The WebSocket library receives complete frames before the SDK checks them, and constructing payloads or serializing a frame can also allocate temporary memory before rejection.
Read application.metrics().boundary for atomic outbound queue, writer, raw
transport, and connector snapshots. Before writer startup, submissions raise
OutboundCommandBusNotReadyError; once outbound command shutdown starts, they
raise OutboundCommandBusClosedError. Accepted commands receive exactly one
terminal result through the canonical CommandFuture.
The runtime uses explicit thread ownership:
| Surface | Supported caller or owner |
|---|---|
configure_logging() and StreamDockApplication.run() / stop() |
Application lifecycle thread; configure logging before run(); stop() is idempotent and may also be called concurrently |
| WebSocket frame I/O and typed protocol parsing | Boundary-owned transport/codec workers |
Wire-event callbacks on Action, Plugin, and PluginHooks |
Runtime-owned keyed workers; callbacks are serial per context and may overlap across contexts, while lifecycle, broadcast, and unknown barriers run exclusively |
Plugin.on_ready() |
Runtime event-pump thread, after session readiness and before inbound protocol callbacks |
Plugin.on_stop() |
Runtime cleanup worker, after action cleanup and before application services stop |
StreamDockSender.send() / send_async() and action command helpers |
Any application, service, or action-callback thread after the outbound writer starts; overlapping calls are supported |
StreamDockApplication.stop() |
Any application or action-callback thread; calls are idempotent and may overlap |
The outbound queue establishes FIFO order when it accepts commands. Calls that
do not overlap retain caller order; the relative order of simultaneous calls
is intentionally unspecified. Each send() waits only for its own accepted
submission (or the final coalesced write) and receives its serialization,
transport, overflow, or shutdown result. send_async() returns after
acceptance; the returned CommandFuture exposes the later result.
Public JSON uses native dict/list contents. OwnedJsonPayload is a dict
subclass with ordinary storage and plain nested containers; base operations
such as dict.items(payload) and list.copy(payload["values"]) see the same
data as normal access. Command constructors deeply isolate their inputs.
ValidatedJsonObject.isolated_copy() and OwnedJsonPayload.isolated_copy()
return independent deep snapshots made entirely of plain dict/list objects.
Events delivered to separate global-settings recipients also own independent
containers. .copy() and copy.copy() remain shallow, and inserting a mutable
value shares that reference within the receiving object, just as in Python.
Migration from COW payloads: mutations now use native container semantics and
are validated when creating an isolated snapshot or preparing a command for
sending. Invalid values, non-string keys, non-finite floats and cycles are
rejected there. Call .isolated_copy() when immediate validation is needed.
to_wire() returns an envelope referencing the command's live payload;
to_validated_wire() captures an independent, validated wire snapshot.
Large public snapshots now require a full container copy; COW remains internal.
Validated JSON objects are limited to 64 nested containers, counting the root
object as level one. Each dictionary or list adds one level; scalar values do
not. The limit applies to settings, Property Inspector payloads and validated
snapshots; a command's routing envelope does not add to its owned payload's
depth. Excessive nesting raises ValueError (or the boundary's codec/protocol
error) with JSON nesting depth exceeds maximum of 64 containers.
Scalar-only frozen command objects may be shared between threads.
Payload-bearing commands own mutable OwnedJsonPayload data: do not mutate a
command or any mutable object reachable from its payload from the start of
send() or send_async() until completion, including through inserted aliases.
ValidatedJsonObject backing snapshots are safe to hand between threads after
construction, but every mutable JSON object—event settings, Action.settings, and
OwnedJsonPayload—allows only one accessing or mutating thread at a time.
application.global_settings.snapshot() is isolated, and its update() method
serializes rollback-safe changes from background services; do not share a live
mutable object between threads. The sending thread owns the submitted payload
until its command completes; build a separate command for independent work.
Shutdown closes the typed boundary, drains owned inbound work while callback
commands can still finish, stops the scheduler and pumps, and finally releases
all action contexts. A background thread that needs to interrupt run() calls
application.stop().
SDK logging is disabled by default: it does not propagate to the application's
root logger and does not create a log file. Enable diagnostics explicitly before
calling run_plugin_cli():
from mirabox_sdk import configure_logging
configure_logging(level="INFO")When enabled without a file, the destination is stderr. To write UTF-8 logs to a file, pass a path; missing parent directories are created automatically. File logging rotates at 5 MiB with three backups by default:
from pathlib import Path
from mirabox_sdk import LoggingOverflowPolicy, configure_logging
configure_logging(
level="DEBUG",
log_file=Path.home() / ".mirabox-counter" / "plugin.log",
max_bytes=5 * 1024 * 1024,
backup_count=3,
logging_queue_limit=1024,
logging_overflow_policy=LoggingOverflowPolicy.DROP_NEWEST,
)Adjust max_bytes and backup_count for the plugin's needs. Set
max_bytes=0 only when intentionally requesting an unbounded file.
logging_queue_limit bounds the number of records waiting for the managed
listener; it must be positive. DROP_NEWEST preserves already queued records,
while DROP_OLDEST keeps the most recent records of the same priority.
include_payload=True adds the complete inbound and outbound protocol message
to DEBUG records. Payloads may contain tokens, settings, and other secrets, so
enable this option only temporarily in a trusted development environment. Omit
the option (its default is False) to return to redacted payloads while keeping
other diagnostics enabled.
configure_logging(
level="DEBUG",
log_file=Path.home() / ".mirabox-counter" / "plugin.log",
include_payload=True,
)Repeated calls replace the handler previously installed by
configure_logging(), draining its queue first, so the level or destination can
be changed without duplicating messages. Return the SDK to its default silent
state and flush pending records with:
configure_logging(enabled=False)INFO records cover connection lifecycle and operational status. Per-message
protocol direction, event, and context are emitted only at DEBUG. Message
payloads remain redacted unless include_payload=True is explicitly configured.
SDK records are handed to one managed logging thread, so stream and rotating
file I/O never runs in the WebSocket reader, inbound dispatcher, outbound
writer, or calling service thread. Handlers installed manually by the
application remain its responsibility and are outside that guarantee. The
managed queue is bounded and producers never wait for destination I/O. On
overflow, an ERROR or CRITICAL record displaces a lower-level record when
possible and is processed ahead of queued DEBUG through WARNING records.
Lower-level records never displace queued errors; within the same priority
class, the configured overflow policy is applied. dropped_log_records()
returns the process-wide, thread-safe count of records discarded by all managed
logging queues:
from mirabox_sdk import dropped_log_records
if dropped_log_records():
# The destination has not kept up with the configured log volume.
...Protocol traces are recorded after successful inbound decoding and outbound
encoding. An outbound trace confirms serialization; command futures report
transport completion. Traces reuse the existing frame without extra JSON
serialization. Calling configure_logging() again with include_payload
omitted restores redaction.
Parser and action/plugin callback failures include a diagnostic category, event,
callback, context when available, safe field path (for example,
$.payload.coordinates), and traceback file/line/function locations. SDK logs
omit exception messages, source text and locals even at DEBUG. Arbitrary keys
inside settings or custom message paths appear as <key>; protocol schema keys
and array indexes are retained.
For programmatic error handling, pass an observer to the application factory:
from mirabox_sdk import SdkDiagnostic, create_stream_dock_application
def observe_error(diagnostic: SdkDiagnostic) -> None:
# Forward selected metadata to your application's diagnostics collector.
# diagnostic.error is the original exception and may contain secrets.
...
application = create_stream_dock_application(
launch_arguments,
action_factory=registry,
action_dependencies_factory=lambda ctx: Dependencies(ctx.stream_dock),
error_observer=observe_error,
)The observer works with logging disabled and is also supported by
StreamDockHarness. It runs synchronously on the reporting worker and may be
called concurrently: keep it thread-safe and return promptly. Each failing
broadcast target is reported separately. Observer exceptions are logged by type
and isolated, preserving delivery to other actions. Plugin on_ready() and
on_stop() failures are reported too. Shutdown and service failures remain
available through shutdown_outcome.
MiraboxStreamDockSDK/
├── pyproject.toml # Package metadata and tool configuration
├── src/mirabox_sdk/
│ ├── action.py # Reusable action base class
│ ├── action_registry.py # Action UUID registry
│ ├── completion.py # Canonical command completion contract
│ ├── commands.py # Typed outbound commands
│ ├── events.py # Typed inbound event models
│ ├── parser.py # Strict wire-message parser
│ ├── runtime/ # Stable application/runtime API
│ ├── _internal/ # Private boundary/dispatcher implementation
│ ├── logging_config.py # Isolated SDK logging configuration
│ └── property_inspector/ # Browser-side SDK resource
├── examples/counter_plugin/ # Complete buildable plugin
├── examples/background_service/ # Complete managed background worker example
├── tests/ # SDK and release-tool tests
├── scripts/ # Version and distribution verification
└── .github/workflows/ # CI and Trusted Publishing release jobs
Install the development dependencies and Node.js 22, then run the same checks as CI:
node --test tests/property_inspector.test.js
python -m unittest discover -s tests -v
PYTHONPATH=examples/counter_plugin/src \
python -m unittest discover -s examples/counter_plugin/tests -v
PYTHONPATH=examples/background_service/src \
python -m unittest discover -s examples/background_service/tests -v
python -m compileall -q src tests scripts examples
ruff check src tests scripts examples
ruff format --check src tests scripts examples
PYTHONPATH=src python scripts/benchmark_runtime_scheduler.py --check
PYTHONPATH=src python scripts/benchmark_runtime_scheduler.py --callback-delay 0 --check --json
PYTHONPATH=src python -m scripts.benchmark_command_latency --include-async-settings
PYTHONPATH=src python -m scripts.benchmark_settings_copies
PYTHONPATH=src python -m scripts.benchmark_runtime_workload --mode mixed --repeats 3 --check
PYTHONPATH=src python -m scripts.benchmark_runtime_workload --mode soak --soak-seconds 120
python -m mypy
python -m build
python scripts/verify_distribution.py dist
python scripts/verify_wheel_typing.py dist
python -m twine check dist/*The dedicated performance job runs this full matrix on Ubuntu 24.04 with
CPython 3.13.11. The runtime-scheduler-benchmark artifact retains every JSON
result, stderr log and shared revision/runner/dependency metadata for 90 days.
Later measurements still run after a failed benchmark, and artifacts upload
even when the job fails.
| Measurement | Scenarios | Result file |
|---|---|---|
| Scheduler gate | Sequential/keyed, 1/4/16/64 contexts, delayed callbacks; coalescing and blocked hot context at pending limits 1/4/16/64 | results.json |
| Zero-delay scheduler | The same context matrix with separate short-callback budgets | scheduler-zero-delay.json |
| Command latency | Sync/async commands and sync/async settings, eight batches each | command-latency.json |
| Settings copies | Small/wide/nested settings, four operations, 31 timing samples each | settings-copies.json |
| Mixed load | Both schedulers with zero/delayed callbacks, 80% hot context, key/settings events and lifecycle/global/unknown barriers; throughput/p95/p99 gates | mixed-load.json |
| Memory soak | Both schedulers, 120 seconds each after warmup, 64 B/4 KiB/64 KiB payload strings | memory-soak.json |
Mixed load checks context FIFO, exclusive barriers, complete acknowledgement and queue count/byte bounds. It measures the typed queue/pump/scheduler path with synthetic callbacks, excluding parsing, action management, commands and device I/O. Mixed timing uses the sample at the median duration of three runs per scenario; ordering and bounds must pass in every run. Short callbacks and an 80% hot context can make worker handoff more expensive than the available parallelism, so these budgets cap regression without requiring a speedup:
| Workload | Minimum keyed/sequential throughput | Maximum keyed callback-start p95 / p99 |
|---|---|---|
| Zero delay, 1/4/16/64 contexts | 0.15 | 15 ms / not gated |
| Mixed 80% hot, zero delay | 0.40 | 75 ms / 100 ms |
| Mixed 80% hot, 0.1 ms delay | 0.55 | 150 ms / 175 ms |
Both scripts report comparisons in JSON and return a failure with --check
when a budget is exceeded. Delayed independent-context budgets remain unchanged.
The accounting comparison
records local before/after measurements used to set these bounds with runner headroom.
Memory runs keep the runtime alive between batches without retaining
callback latency history. Every five seconds, after drain and garbage collection,
they sample Linux RSS, tracemalloc retained/peak bytes and released queue bytes;
initial/final samples and retained-memory growth are included. Workload totals
include the reported warmup events; sample elapsed times start after warmup.
RSS is null outside Linux. Memory growth has no CI threshold; scheduler timing
budgets and workload invariants are enforced.
These synthetic measurements do not establish device throughput or rule out
longer-term memory leaks. Increase --soak-seconds for longer local runs.
The PERF-04 baseline records slow-sender command p95/p99, occupied workers, input latency and settings snapshot latency before and after async persistence. These are small synthetic samples through real queues, the event pump, scheduler and writer, with a controlled sender; they exclude device I/O and incoming JSON decoding. The benchmark measures blocking without imposing a machine-dependent timing gate.
The PERF-05 baseline records settings latency, per-call retained/peak memory and profiled clone counts for small, wide and nested objects. Global snapshots and replay make one deep copy; the default runtime registry reuses the context manager's isolated settings, so parsing through action creation makes two copies. Custom registry and action decoder overrides retain their existing path. Timing, memory and profiling run separately; JSON text decoding, device I/O and callbacks are excluded, and no timing gate applies.
The test suite uses fake connections and protocol messages; it does not require a running Stream Dock instance. CI runs the SDK on Linux and Windows across all supported Python versions.
Mypy follows imports across SDK sources and both examples. CI also checks the
generated project's sources and builds/validates both new bundles on Windows. The wheel
typing check installs the built package and pinned mypy in a temporary virtual
environment outside the checkout. It checks consumer fixtures, rejects invalid
calls, guards against Any, and verifies JSON stubs and public runtime type
hints. Private JSON container implementation typing is deferred behind the
checked json_types.pyi boundary. This check needs package-index access to
install mypy and the wheel's dependencies.
Contributions are welcome. Please read CONTRIBUTING.md before submitting a change, and include the Stream Dock version and a regression test when changing observed protocol behavior.
Releases are built from version tags, published to PyPI through Trusted Publishing, and attached to a generated GitHub Release. The required one-time configuration and release checklist are documented in RELEASING.md.
Distributed under the MIT License.