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).

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_jwt field.
  • 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:

  1. Client creates a unique reply subject: {requestSubject}.{NewGuid()}
  2. Client subscribes to that reply subject
  3. Client publishes the request to {requestSubject} with replyTo set to the reply subject
  4. Server sends the response (potentially in multiple chunks) to the reply subject
  5. Client reads from the reply subscription until the full response is assembled
  6. 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 OpenTapNatsError header 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 (NatsNoRespondersException or 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)

  1. Encode the full payload as UTF-8 JSON bytes.
  2. If the payload fits within one chunk, send it as a single message with no chunk headers.
  3. 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.

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:

  1. Send Shutdown to the session:
    request(sessionBase + ".Request.Shutdown")
    
  2. Send ShutdownSession to 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 JsonStringEnumConverter with 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 from Loading → Idle or Idle → Executing may be missed otherwise.
  • RunTestPlan returns immediately; the test plan runs asynchronously. Poll GetStatus or use SessionStateChanged events to detect completion.
  • After Shutdown returns, the session process has stopped. Any further requests to the session will receive a no-responders error.
  • It is safe to call ShutdownSession even if the session has already exited; the Runner will return success.