Implementation Guide
This document describes the wire-level protocol for building an OpenTAP Runner client in any programming language. It covers NATS connection setup, the custom request-reply pattern, chunked transfer, event subscriptions, session lifecycle, JetStream usage, and JSON serialization rules. Read it alongside the API Reference, which lists every endpoint and DTO.
A client built from these two documents alone should be able to connect to a running Runner, create a session, load and execute a test plan, collect results, and shut down cleanly.
1. NATS Connection Setup
Default URL
A locally installed Runner listens on:
nats://127.0.0.1:20111
When connecting to a cloud-hosted Runner, the URL is supplied by the cloud platform alongside JWT credentials (see Authentication below).
Recommended Connection Options
Match these options on your NATS client to align with the server’s expectations:
| Option | Value | Notes |
|---|---|---|
MaxReconnect |
Forever / -1 | Never stop retrying |
ReconnectWait |
1000 ms | Pause between attempts |
AllowReconnect |
true | Required for reconnect to work |
PingInterval |
45 000 ms | Keep-alive interval |
ConnectTimeout |
5 000 ms | Initial connection timeout |
Authentication
Local Runner — No credentials are required. Connect without authentication.
Cloud Runner — Authenticate with a JWT and NKey seed pair:
- On connection, supply the JWT in the NATS
user_jwtfield. - Sign server nonces using the NKey seed (NaCl Ed25519 signature).
- The specific JWT and seed are provided by the KS8500 cloud platform.
Deriving the RunnerId
The RunnerId is the NATS server’s reported name, available immediately after the connection handshake as connection.ServerInfo.Name (or the equivalent field in your NATS client library). All subsequent subject construction requires this value.
C# example:
await using INatsConnection connection = new NatsConnection(new NatsOpts
{
Url = "nats://127.0.0.1:20111"
});
// ServerInfo is populated after the connection is established.
string runnerId = connection.ServerInfo.Name;
string runnerBase = $"OpenTap.Runner.{runnerId}";
2. Subject Hierarchy
All communication uses NATS subjects derived from two base templates. Substitute {RunnerId} with the value derived in section 1.
Base Templates
| Name | Pattern |
|---|---|
| Runner base | OpenTap.Runner.{RunnerId} |
| Session base | OpenTap.Runner.{RunnerId}.Session.{SessionId} |
{SessionId} is a UUID (lowercase, no braces) returned by session-creation endpoints.
Runner Subjects
| Purpose | Subject |
|---|---|
| Runner request | OpenTap.Runner.{RunnerId}.Request.{EndpointName} |
| Runner lifecycle Started | OpenTap.Runner.{RunnerId}.Events.Lifetime.Started |
| Runner lifecycle Stopped | OpenTap.Runner.{RunnerId}.Events.Lifetime.Stopped |
| Runner lifecycle Heartbeat | OpenTap.Runner.{RunnerId}.Events.Lifetime.Heartbeat |
| Runner executing state | OpenTap.Runner.{RunnerId}.Events.Running |
| Runner metadata updated | OpenTap.Runner.{RunnerId}.Events.MetadataUpdated |
Session Subjects
| Purpose | Subject |
|---|---|
| Session request | {SessionBase}.Request.{EndpointName} |
| Session event | {SessionBase}.Events.{EventName} |
| Session logs (streaming) | {SessionBase}.SessionLogs |
| Plan run (start/complete) | {SessionBase}.PlanRun.{PlanRunId} |
| Plan run parameters | {SessionBase}.PlanRun.{PlanRunId}.Parameters |
| Plan run logs | {SessionBase}.PlanRun.{PlanRunId}.Logs |
| Plan run artifact | {SessionBase}.PlanRun.{PlanRunId}.Artifact.{Guid} |
| Step run (start/complete) | {SessionBase}.PlanRun.{PlanRunId}.StepRun.{StepRunId} |
| Step run parameters | {SessionBase}.PlanRun.{PlanRunId}.StepRun.{StepRunId}.Parameters |
| Step run result table | {SessionBase}.PlanRun.{PlanRunId}.StepRun.{StepRunId}.Result.{TableName} |
| Metrics | {SessionBase}.Metrics.{Group}.{Name} |
{TableName} has NATS-unsafe characters (spaces, dots, wildcards, >) replaced with underscores by the server.
NATS Subscription Wildcards
When subscribing to result streams, use NATS wildcards:
| Wildcard | Meaning |
|---|---|
* |
Exactly one token |
> |
One or more tokens (only at end) |
Typical result subscriptions:
{SessionBase}.PlanRun.* → plan run start and complete
{SessionBase}.PlanRun.*.StepRun.* → step run start and complete
{SessionBase}.PlanRun.*.StepRun.*.Result.> → all result tables
{SessionBase}.PlanRun.*.Logs → plan run log batches
3. Request-Reply Protocol
Overview
The Runner does not use the standard NATS request-reply pattern. Instead, the client implements a custom pattern using an explicit reply subscription:
- Client creates a unique reply subject:
{requestSubject}.{NewGuid()} - Client subscribes to that reply subject
- Client publishes the request to
{requestSubject}withreplyToset to the reply subject - Server sends the response (potentially in multiple chunks) to the reply subject
- Client reads from the reply subscription until the full response is assembled
- Client unsubscribes from the reply subject
This is necessary because the response may be chunked across multiple messages (see section 4), which standard request-reply does not support.
Headers
The following NATS message headers are used in requests:
| Header | When Present | Value |
|---|---|---|
Authorization |
Endpoints that require a token | The access token string |
RequestId |
Multi-chunk request payloads | A new UUID per request |
ChunkSize |
Multi-chunk request payloads | Chunk size in bytes (integer string) |
ChunkNumber |
Multi-chunk request payloads | 1-indexed chunk sequence number |
Default Timeout
The default request timeout is 1 minute. There is no built-in retry. If the server does not respond within the timeout, treat the request as failed.
Error Detection
Before deserializing a response, inspect its headers:
- If the
OpenTapNatsErrorheader is present (any value), the response body is an error object:{ "Message": "Human-readable error description" }Raise an exception with that message.
- If the NATS layer reports no subscribers (
NatsNoRespondersExceptionor equivalent), the runner or session is not available on that subject.
Pseudocode
function request(subject, payload, token=null, timeout=60s):
replySubject = subject + "." + newGuid()
subscription = nats.subscribe(replySubject)
try:
headers = {}
if token != null:
headers["Authorization"] = token
encodedPayload = jsonEncode(payload) // null → empty byte array
if len(encodedPayload) > chunkSize:
publishInChunks(subject, encodedPayload, headers, replySubject)
else:
nats.publish(subject, encodedPayload, headers, replyTo=replySubject)
chunks = []
deadline = now() + timeout
while now() < deadline:
msg = subscription.readNext(deadline - now())
if msg.hasNoResponders:
raise NoRespondersError
if "OpenTapNatsError" in msg.headers:
raise RunnerError(jsonDecode(msg.body).Message)
if msg.body.length > 0:
chunks.append(msg.body)
if "ChunkSize" not in msg.headers:
break
chunkSize = int(msg.headers["ChunkSize"])
if msg.body.length != chunkSize:
break // last chunk
raise TimeoutError if deadline passed
return jsonDecode(concatenate(chunks))
finally:
subscription.unsubscribe()
C# Example
// BaseClient.RequestAsync<T> — simplified excerpt
INatsSub<byte[]> sub = await connection.SubscribeCoreAsync<byte[]>($"{subject}.{Guid.NewGuid()}");
try
{
var headers = new NatsHeaders();
if (useToken && Token != null)
headers.Add("Authorization", Token);
byte[] payload = JsonSerializer.SerializeToUtf8Bytes(data, RunnerSerialization.DefaultSerializerOptions);
if (payload.Length > _chunkSize)
await PublishInChunksAsync(subject, payload, headers, sub.Subject, ct);
else
await connection.PublishAsync(subject, payload, headers, replyTo: sub.Subject, cancellationToken: ct);
List<byte[]> chunks = new();
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(timeout);
while (!cts.Token.IsCancellationRequested)
{
NatsMsg<byte[]> msg = await sub.Msgs.ReadAsync(cts.Token);
if (msg.HasNoResponders)
throw new NatsNoRespondersException();
if (msg.Headers?.ContainsKey("OpenTapNatsError") == true)
throw new RunnerException(JsonSerializer.Deserialize<RunnerError>(msg.Data).Message);
if (msg.Data?.Length > 0)
chunks.Add(msg.Data);
if (msg.Headers == null || !msg.Headers.TryGetValue("ChunkSize", out var cs))
break;
if (msg.Data?.Length != int.Parse(cs))
break;
}
return JsonSerializer.Deserialize<T>(CombineChunks(chunks));
}
finally
{
await sub.UnsubscribeAsync();
}
4. Chunked Transfer Protocol
Large messages — in both directions — are split into chunks. Every endpoint that accepts or returns a payload may involve chunking. Clients must handle chunks even for small payloads, because the server may choose to chunk any response.
Chunk Size
| Side | Calculation | Typical Value |
|---|---|---|
| Client (sending) | MaxPayload − 10 KB |
~1 MB on a default NATS server |
| Client (fallback) | 1 MB | Used when MaxPayload is unavailable |
| Server (publishing results/events) | 90 KB (fixed) | Aimed at staying under 100 KB per message |
| Server (request handler) | MaxPayload − 50 KB |
Conservative reservation for headers |
MaxPayload is advertised by the NATS server in the connect INFO message (connection.ServerInfo.MaxPayload).
Sending Chunks (Client → Server)
- Encode the full payload as UTF-8 JSON bytes.
- If the payload fits within one chunk, send it as a single message with no chunk headers.
- If the payload exceeds one chunk:
- Add headers to every message:
RequestId: a new UUID string (same value for all chunks of one request)ChunkSize: the chunk size in bytes (integer string)ChunkNumber: 1-indexed position of this chunk (integer string)
- Send chunks sequentially.
- Trailing empty message: if the final chunk’s data length is exactly equal to
ChunkSize(i.e., no partial last chunk), send one additional message with an empty body. This is also required when the total payload is empty (zero bytes). This sentinel tells the receiver that no more chunks are coming.
- Add headers to every message:
Receiving Chunks (Client ← Server)
Read messages from the reply subscription in a loop:
chunks = []
while true:
msg = read_next()
if msg.body.length > 0:
chunks.append(msg.body)
if "ChunkSize" not in msg.headers:
break // single-message response (no chunking in use)
chunkSize = int(msg.headers["ChunkSize"])
if msg.body.length != chunkSize:
break // this message is shorter than a full chunk → it is the last one
// reassemble: concatenate all chunks in order
payload = concatenate(chunks)
The last chunk is detected by its size being strictly less than ChunkSize (or empty). The server never sends a ChunkNumber count in advance; the client discovers the end by size.
Server-Side Reassembly
The server reassembles chunked requests using a RequestHandler keyed on RequestId:
- Supports out-of-order delivery (chunks are stored by index).
- Maximum 1000 chunks per request (~1 GB at the default NATS max payload).
- Incomplete requests expire after 60 seconds of inactivity and are discarded.
C# Example (Sending)
// PublishInChunksAsync — from BaseClient.cs
private async Task PublishInChunksAsync(string subject, ReadOnlyMemory<byte> payload,
NatsHeaders headers, string replyTo, CancellationToken ct)
{
headers.Add("RequestId", Guid.NewGuid().ToString());
headers.Add("ChunkSize", _chunkSize.ToString());
int sent = 0, chunkNumber = 1;
while (sent < payload.Length)
{
var chunkHeaders = CloneHeaders(headers);
chunkHeaders["ChunkNumber"] = chunkNumber.ToString();
int count = Math.Min(payload.Length - sent, _chunkSize);
await connection.PublishAsync(subject, payload.Slice(sent, count).ToArray(),
chunkHeaders, replyTo, cancellationToken: ct);
sent += count;
chunkNumber++;
}
// Sentinel: send empty message if last chunk was exactly full (or payload was empty)
if (payload.Length % _chunkSize == 0)
{
headers["ChunkNumber"] = chunkNumber.ToString();
await connection.PublishAsync(subject, Array.Empty<byte>(), headers, replyTo, cancellationToken: ct);
}
}
5. Session Lifecycle
Session States
Sessions progress through a defined set of states. The current state is always available via GetStatus and is also broadcast in SessionStateChanged events.
| Value | Integer | Description |
|---|---|---|
Idle |
0 | Session is ready; no test plan is executing |
Executing |
1 | Test plan is running |
Breaking |
2 | Execution is paused at a breakpoint |
Aborting |
3 | Abort requested; executing cleanup |
WaitingForUserInput |
4 | Execution paused; awaiting user input |
Loading |
5 | Session is initialising; not yet ready |
[Created] → Loading → Idle → Executing → Idle
↕
Breaking
↕
WaitingForUserInput
↓
Aborting → Idle
Creating a Session
Call one of the session-creation endpoints on the Runner:
| Endpoint | Description |
|---|---|
NewSession |
Full-featured creation: image, defaults, optional auto-run and auto-close |
StartSession |
Simple session using Runner defaults |
StartImageSession |
Session using a specific image |
All return a Session object containing the session’s UUID (Id). Construct the session base subject immediately:
sessionBase = "OpenTap.Runner.{RunnerId}.Session.{session.Id}"
Waiting for Readiness
A newly created session starts in the Loading state and is not immediately ready to accept commands. Wait for SessionState.Idle before sending requests.
Option A — Poll:
while true:
status = request(sessionBase + ".Request.GetStatus")
if status.SessionState == "Idle":
break
sleep(250ms)
Option B — Subscribe:
subscribe(sessionBase + ".Events.SessionStateChanged", handler = function(event):
if event.RunStatus.SessionState == "Idle":
markReady()
)
Shutdown Sequence
Always shut down sessions explicitly to release resources:
- Send
Shutdownto the session:request(sessionBase + ".Request.Shutdown") - Send
ShutdownSessionto the runner (passing the session UUID):request(runnerBase + ".Request.ShutdownSession", sessionId)
Watchdog / Heartbeat
| Interval | Description |
|---|---|
| Runner heartbeat | Every 15 seconds, published to Events.Lifetime.Heartbeat |
| Session heartbeat | Every 5 seconds, published to Events.Heartbeat |
| Session inactivity timeout | Default 1800 seconds (30 minutes); configurable via TerminationTimeout |
| Runner kill threshold | Sessions not seen for 7 seconds in production (1800 s in development mode) are terminated |
If your client needs to maintain a long-lived session without activity, monitor the session heartbeat events and ensure the NATS connection stays alive.
6. Event and Subscription Model
Events are published as fire-and-forget core NATS messages (not JetStream). Subscribe before creating a session or starting a test plan to avoid missing early events.
Runner Events
Subscribe to these on the runner base subject (OpenTap.Runner.{RunnerId}.{suffix}):
StartedLifetimeEvent
Published when the Runner process has started.
Subject: Events.Lifetime.Started
No payload fields.
StoppedLifetimeEvent
Published when the Runner process is stopping.
Subject: Events.Lifetime.Stopped
No payload fields.
HeartbeatLifetimeEvent
Published every 15 seconds. Contains a snapshot of all currently active sessions.
Subject: Events.Lifetime.Heartbeat
| Property | Type | Description |
|---|---|---|
Sessions |
List of SlimSession | All sessions currently alive on this Runner |
RunningEvent
Published when the Runner’s overall executing state changes.
Subject: Events.Running
| Property | Type | Description |
|---|---|---|
IsRunning |
Boolean | Whether any session is currently executing a test plan |
MetadataUpdatedEvent
Published when Runner-level metadata changes (e.g. after a session is registered or updated).
Subject: Events.MetadataUpdated
| Property | Type | Description |
|---|---|---|
SessionId |
Guid | The session whose metadata changed |
Metadata |
Dictionary of String → String | Updated key/value metadata map |
Session Events
All session events are published to {SessionBase}.Events.{EventName}. Each event type has its own subject; there is no single wildcard subject that captures all session events. Subscribe to each individually.
TypeCacheInvalidatedEvent
Published when the session’s type cache has been invalidated and must be refreshed.
Subject: Events.TypeCacheInvalidated
No payload fields.
TestPlanSettingsChangedEvent
Published when the test plan settings associated with the session have changed.
Subject: Events.TestPlanSettingsChanged
No payload fields.
SessionTimeoutEvent
Published immediately before the session terminates due to watchdog inactivity timeout.
Subject: Events.SessionTimeout
No payload fields.
StartingEvent
Published when a test plan execution is about to begin (sequencer is transitioning into Executing).
Subject: Events.Starting
No payload fields.
StartedEvent
Published after a test plan execution has started and the first step is about to run.
Subject: Events.Started
No payload fields.
HeartbeatEvent
Published every 5 seconds while the session is alive. Use this to monitor session health and watchdog state.
Subject: Events.Heartbeat
| Property | Type | Description |
|---|---|---|
Timestamp |
Int64 | Unix timestamp in seconds |
WatchDog |
WatchDog | Watchdog state: inactivity counter and termination threshold |
State |
SessionState | Current session state |
TestPlanRunID |
Guid (nullable) | Active plan run ID, or null if not executing |
WatchDog fields:
| Property | Type | Description |
|---|---|---|
InactiveSeconds |
Double | Seconds elapsed since last activity |
TerminationTimeout |
Int32 | Seconds of inactivity before the session is terminated (default 1800) |
StoppingEvent
Published when a test plan execution is in the process of stopping (e.g. after abort or normal completion, before teardown is complete).
Subject: Events.Stopping
No payload fields.
StoppedEvent
Published when a test plan execution has fully stopped and the session has returned to Idle.
Subject: Events.Stopped
No payload fields.
TestPlanChangedEvent
Published whenever the in-memory test plan is modified (step added, removed, reordered, or a property changed).
Subject: Events.TestPlanChanged
| Property | Type | Description |
|---|---|---|
EditStatus |
EditStatus | Current dirty/undo/redo state of the test plan editor |
EditStatus fields:
| Property | Type | Description |
|---|---|---|
TestPlanDirty |
Boolean | Whether there are unsaved changes |
UndoBufferSize |
Int32 | Number of available undo steps |
RedoBufferSize |
Int32 | Number of available redo steps |
SessionStateChangedEvent
Published whenever the session’s execution state changes (e.g. Idle → Executing, Executing → Idle). This is the primary event for tracking execution progress.
Subject: Events.SessionStateChanged
| Property | Type | Description |
|---|---|---|
RunStatus |
RunStatus | Full status snapshot including state, verdict, and active plan run ID |
SettingsChangedEvent
Published when a component settings group has been modified.
Subject: Events.SettingsChanged
| Property | Type | Description |
|---|---|---|
Group |
String | Component settings group name |
Name |
String | Component settings name within the group |
TestStepChangedEvent
Published when a specific test step has been modified.
Subject: Events.TestStepChanged
| Property | Type | Description |
|---|---|---|
StepId |
Guid | ID of the test step that changed |
BreakEvent
Published when execution pauses at a breakpoint. The session enters Breaking state.
Subject: Events.Break
| Property | Type | Description |
|---|---|---|
StepId |
Guid | ID of the step where execution broke |
UserInputRequestedEvent
Published when a test step requires user input before it can continue. The session enters WaitingForUserInput state.
Subject: Events.UserInputRequested
| Property | Type | Description |
|---|---|---|
RequestId |
Guid | Identifier of the pending input request; use it with GetUserInput and submit via SetUserInput |
UserInputRequestCompletedEvent
Published when a pending user input request has been resolved (answered, cancelled, or timed out).
Subject: Events.UserInputRequestCompleted
| Property | Type | Description |
|---|---|---|
RequestId |
Guid | Identifier of the completed input request |
Answered |
Boolean | True if the user provided input; false if cancelled |
Timeout |
Boolean | True if the request timed out without input |
Subscribing to Session Events — C# Example
// SessionClient.ConnectSessionEvents — simplified excerpt
void SubscribeEvent<TEvent>(string eventName) where TEvent : SessionEvent
{
Task.Run(async () =>
{
await foreach (var msg in connection.SubscribeAsync<TEvent>(
$"{sessionBase}.Events.{eventName}", cancellationToken: cts.Token))
{
if (msg.Data != null)
await handler(msg.Data);
}
});
}
SubscribeEvent<TypeCacheInvalidatedEvent>("TypeCacheInvalidated");
SubscribeEvent<TestPlanSettingsChangedEvent>("TestPlanSettingsChanged");
SubscribeEvent<SessionTimeoutEvent>("SessionTimeout");
SubscribeEvent<StartingEvent>("Starting");
SubscribeEvent<StartedEvent>("Started");
SubscribeEvent<HeartbeatEvent>("Heartbeat");
SubscribeEvent<StoppingEvent>("Stopping");
SubscribeEvent<StoppedEvent>("Stopped");
SubscribeEvent<TestPlanChangedEvent>("TestPlanChanged");
SubscribeEvent<SessionStateChangedEvent>("SessionStateChanged");
SubscribeEvent<SettingsChangedEvent>("SettingsChanged");
SubscribeEvent<TestStepChangedEvent>("TestStepChanged");
SubscribeEvent<BreakEvent>("Break");
SubscribeEvent<UserInputRequestedEvent>("UserInputRequested");
SubscribeEvent<UserInputRequestCompletedEvent>("UserInputRequestCompleted");
Session Logs (Streaming)
Real-time log output is published to {SessionBase}.SessionLogs as LogList messages (a list of LogEntry). Subscribe before starting the session if you need complete logs.
Result Streams
Results are published as test plan execution proceeds. Use NATS wildcard subscriptions:
| Subject pattern | Payload type | Trigger |
|---|---|---|
{SessionBase}.PlanRun.* |
JSON object with Status = "TestPlanRunStart" or "TestPlanRunCompleted" |
Plan run start / complete |
{SessionBase}.PlanRun.*.StepRun.* |
JSON object with Status = "TestStepRunStart" or "TestStepRunCompleted" |
Step run start / complete |
{SessionBase}.PlanRun.*.StepRun.*.Result.> |
Result |
Result table published by a test step |
{SessionBase}.PlanRun.*.Logs |
List<LogEntry> |
Log batch during plan run; null body signals end of run |
{SessionBase}.PlanRun.*.Artifact.* |
Binary stream | Artifact file; Name NATS header contains the filename |
Messages on plan run and step run subjects carry a Seq NATS header (unsigned integer string) which can be used to detect gaps or reorder messages. The sequence number is scoped to the session and resets to 0 at the start of each plan run.
Artifacts are published as a raw binary stream in CHUNK_SIZE (90 KB) pieces terminated by a null/empty message. Read them the same way as chunked responses (section 4), but the Name header is only present on the first message.
Metrics
Metrics are published exclusively to JetStream on {SessionBase}.Metrics.{Group}.{Name}. They are stored in the Metric stream with a subject transform to Metric.{Group}.{Name}. Use a JetStream consumer to read historical or live metrics.
7. JetStream vs Core NATS
Core NATS (fire-and-forget pub/sub)
Used for:
- All request-reply (section 3)
- All event subscriptions (section 6)
- Session log streaming
These messages are not persisted. If you are not subscribed when a message is published, it is lost.
JetStream (persistent streams)
Used for:
- Test plan run results (plan runs, step runs, result tables, parameters)
- Plan run logs
- Artifacts
- Metrics
Runs stream configuration:
| Setting | Value |
|---|---|
| Name | Runs |
| Storage | File |
| Retention | Interest (kept while at least one consumer is active) |
| Discard | New (new messages dropped when stream is full) |
| MaxBytes | Configurable (default unlimited; monitor disk usage) |
| Subjects | Conditionally: plan runs, step runs, parameters, results, logs, artifacts |
Metric stream configuration:
| Setting | Value |
|---|---|
| Name | Metric |
| Storage | File |
| Retention | Limits |
| Discard | Old |
| Max age | 2 days |
| Subject transform | {SessionBase}.Metrics.> → Metric.> |
Whether JetStream is available depends on the Runner’s configuration. If the Runs stream does not exist, results and logs are published as core NATS messages instead (and may be missed if not subscribed at the right time). In practice, always subscribe to result and log subjects before starting execution regardless of JetStream availability.
8. JSON Serialization
Wire Format
All message bodies are UTF-8 encoded JSON. There is no binary envelope or framing — the raw JSON bytes are the NATS message payload (potentially across multiple chunks).
Null Handling
Both the server and client omit null-valued properties from serialized output. When deserializing, treat absent properties as null. Do not reject messages that are missing optional fields.
Enum Serialization
Enums are serialized as PascalCase strings on the wire, regardless of which side produces them:
- Server uses Newtonsoft.Json
StringEnumConverter(default PascalCase). - Client uses System.Text.Json
JsonStringEnumConverterwith per-property overrides to ensure PascalCase output on properties that carry enums.
Deserialization is case-insensitive. When building a client, always serialize enums as PascalCase strings and accept any casing.
Examples:
{ "SessionState": "Idle" }
{ "Verdict": "Pass" }
{ "LayoutMode": "FullRow" }
Polymorphic Types
Three object hierarchies use explicit discriminator properties to identify concrete types on the wire.
| Base type | Discriminator property | Example values |
|---|---|---|
ComponentSettingsBase |
ComponentSettingsType |
ComponentSettings, ComponentSettingsList, ComponentSettingsIdentifier |
TestStep |
TestStepTypeOrInstance |
TestStep, TestStepType, TestStepCopy |
Setting |
ControlType |
TextBoxControl, DataGridControl, DropdownControl, … |
When serializing objects of these types, include the discriminator. When deserializing, use the discriminator to select the concrete class, or retain raw JSON when the type is unknown.
AllowOutOfOrderMetadataProperties
The client’s System.Text.Json options include AllowOutOfOrderMetadataProperties = true. Do not assume discriminator or metadata properties appear first in the JSON object.
Recommendations for New Client Implementations
- Serialize enum values as PascalCase strings.
- Omit null properties from outgoing messages.
- Use case-insensitive enum deserialization.
- Accept unknown properties without error (forward compatibility).
- Accept discriminator / metadata properties at any position in the object.
9. Error Handling
Application-Level Errors
When the Runner or Session encounters an error processing a request, it responds with:
- NATS header
OpenTapNatsError: true - Body:
{ "Message": "Human-readable description of the error" }
Always check for the OpenTapNatsError header before attempting to deserialize a response as the expected type.
Transport-Level Errors
| Condition | Cause | Action |
|---|---|---|
| No responders | No subscriber on the request subject | The Runner or Session is not running, or the subject is wrong |
| Timeout | No response within 1 minute | The Runner is overloaded or the request subject is wrong |
| Connection closed | NATS connection dropped | Reconnect (use MaxReconnect=Forever) |
| Chunk reassembly expiry | Request chunks did not arrive within 60 s | Retry the full request |
JetStream Errors
| Error code | Meaning |
|---|---|
10077 |
Buffer full — test plan execution pauses until disk space is freed or connectivity is restored |
10047 |
Insufficient storage — the configured MaxBytes exceeds available disk space |
10. Complete Workflow Example
This example walks through the most common workflow end-to-end: connect to a Runner, create a session, run a test plan, collect results, and shut down.
Pseudocode
// 1. Connect
connection = nats.connect("nats://127.0.0.1:20111")
runnerId = connection.serverInfo.name
runnerBase = "OpenTap.Runner." + runnerId
// 2. Create a session
response = request(runnerBase + ".Request.NewSession", {
"UseDefaults": true,
"RunTestPlan": false
})
sessionId = response.Session.Id
sessionBase = runnerBase + ".Session." + sessionId
// 3. Wait for the session to become ready
while true:
status = request(sessionBase + ".Request.GetStatus")
if status.SessionState == "Idle": break
sleep(250ms)
// 4. Subscribe to events, logs, and results (before loading/running)
subscribe(sessionBase + ".Events.SessionStateChanged", onSessionStateChanged)
subscribe(sessionBase + ".Events.Heartbeat", onHeartbeat)
subscribe(sessionBase + ".SessionLogs", onSessionLog)
subscribe(sessionBase + ".PlanRun.*", onTestRun)
subscribe(sessionBase + ".PlanRun.*.StepRun.*", onTestRun)
subscribe(sessionBase + ".PlanRun.*.StepRun.*.Result.>", onResult)
// 5. Load a test plan XML
warnings = request(sessionBase + ".Request.SetTestPlanXML", testPlanXml)
// 6. Execute (optionally pass parameters)
request(sessionBase + ".Request.RunTestPlan", parameters)
// 7. Wait for execution to finish
while true:
event = waitFor(onSessionStateChanged)
if event.RunStatus.SessionState == "Idle": break
// handle Breaking / WaitingForUserInput / Aborting as needed
// 8. Shutdown
request(sessionBase + ".Request.Shutdown")
request(runnerBase + ".Request.ShutdownSession", sessionId)
// 9. Disconnect
connection.close()
C# Example
// 1. Connect
await using INatsConnection connection = new NatsConnection(new NatsOpts
{
Url = RunnerClient.DefaultUrl // "nats://127.0.0.1:20111"
});
// 2. Create clients
RunnerClient runner = new RunnerClient(connection);
SessionClient session = await runner.StartSessionAsync();
// 3. Wait for session to become ready
RunStatus status;
do
{
await Task.Delay(250);
status = await session.GetStatusAsync();
} while (status.SessionState != SessionState.Idle);
// 4. Subscribe to events and results
var cts = new CancellationTokenSource();
session.ConnectSessionEvents(async evt =>
{
if (evt is SessionStateChangedEvent sc)
Console.WriteLine($"State: {sc.RunStatus.SessionState}");
}, cts.Token);
session.ConnectSessionResults(
resultHandler : async result => Console.WriteLine($"Result: {result?.Name}"),
testRunHandler : async testRun => Console.WriteLine($"TestRun: {testRun?.Status}"),
token : cts.Token
);
session.ConnectSessionLogs(async logs =>
{
foreach (var entry in logs?.Logs ?? [])
Console.WriteLine($"[{entry.Source}] {entry.Message}");
}, cts.Token);
// 5. Load and run
List<string> loadWarnings = await session.SetTestPlanXMLAsync(testPlanXml);
foreach (string warning in loadWarnings)
Console.WriteLine($"Load warning: {warning}");
await session.RunTestPlanAsync(new List<Parameter>());
// 6. Wait for completion
do
{
await Task.Delay(500);
status = await session.GetStatusAsync();
} while (status.SessionState != SessionState.Idle);
Console.WriteLine($"Verdict: {status.Verdict}");
// 7. Shutdown
cts.Cancel();
await session.ShutdownAsync();
await runner.ShutdownSessionAsync(session.Id);
Key Points
- Always subscribe to events and results before calling
RunTestPlan. Events published during the transition fromLoading → IdleorIdle → Executingmay be missed otherwise. RunTestPlanreturns immediately; the test plan runs asynchronously. PollGetStatusor useSessionStateChangedevents to detect completion.- After
Shutdownreturns, the session process has stopped. Any further requests to the session will receive a no-responders error. - It is safe to call
ShutdownSessioneven if the session has already exited; the Runner will return success.