Skip to content
Start free evaluation

Spooling

Spooling is the GEM capability that keeps outgoing messages on the equipment while communication with the host is down. When communication returns, the host asks the equipment to transmit or purge what it kept. Use it for data you can’t afford to lose, such as event reports (S6F11) and alarm reports (S5F1).

In this tutorial you run spool selection, transmission, purging, delivery proofs and a file-backed spool with a PDComm example.

  • Choose the streams and functions to spool with S2F43, and read the RSPACK and STRACK reply.
  • Make the equipment transmit or purge its spool with S6F23, and read the RSDA reply.
  • See how delivery proof differs for messages with and without the W-bit.
  • Confirm that a file-backed spool keeps its entries across restarts.
  • Set PDCOMM_ROOT, PDCOMM_SDK, PDCOMM_NATIVE and PDCOMM_EXAMPLES as described in Set up your environment. In PowerShell the variables are $Root, $Sdk, $Native and $Examples.
  • Install the .NET 10 SDK.
  • Know the basic event and alarm flows from Reports and events and Alarms.
Message Direction Purpose Reply code
S2F43/S2F44 Reset Spooling Streams and Functions Host → equipment Choose the streams and functions to spool. An empty function list means the whole stream; an empty stream list turns spooling off. RSPACK, plus one STRACK per rejected stream
S6F23/S6F24 Request Spooled Data Host → equipment RSDC 0 transmits, 1 purges. RSDA
S2F59/S2F60 Host → equipment Reads the current spool selection of a PDComm equipment. -
Code Values
RSPACK 0 accepted, 1 rejected. A rejected request leaves the selection unchanged.
STRACK 1 spooling isn’t allowed for the stream, 2 unknown stream, 3 unknown function for the stream, 4 a secondary function was specified
RSDA 0 accepted, 1 rejected because a transmission or delivery is pending, 2 nothing is spooled

PDComm makes your application call each spooling step. Nothing is sent automatically.

ConfigureSpooling(catalog, file) spoolable messages, optional snapshot file
ConfigureLocalSpooling(policy) Enabled, MaxTransmit, overflow, selection
|
v
SyncSpool(now) -- communication down --> spooling ACTIVE
|
CaptureSpool / CaptureSpoolEvent / CaptureSpoolAlarm --> entry stored
|
host S6F23 Transmit --> RespondSpool --> transmission run starts
|
SendNextSpool --> delivery --> proof: Response (W-bit) or Written (no W-bit)
|
CompleteSpoolDelivery(result, success) --> entry removed
  • Catalog: the messages that may be spooled. Set it once, before you start, with GemEquipment.ConfigureSpooling. Stream 1 can’t be spooled.
  • Selection: the streams and functions that are actually spooled. The host can change it with S2F43, but only within the catalog.
  • Policy: whether spooling may activate, the maximum number of entries per transmission (MaxTransmit, 0 for no limit), and what happens when the spool is full.
  • Synchronization: call GemEquipment.SyncSpool after you handle events. While communication is down and the policy allows it, spooling activates. now is a timestamp of your choosing; PDComm doesn’t interpret it.
  • Capture: while spooling is active, GemEquipment.CaptureSpool stores a selected message as an entry. CaptureSpoolEvent and CaptureSpoolAlarm capture events and alarms.
  • Delivery: accepting an S6F23 Transmit with GemEquipment.RespondSpool starts a transmission run. Call GemEquipment.SendNextSpool once per entry.
  • Delivery proof: an entry with the W-bit is proven by the host’s reply (Response); an entry without it, by the whole frame having been written (Written). Written proves only the local write, not that the host received it (GemSpoolDeliveryProof).
  • Removal: check the reply body, then report success with GemEquipment.CompleteSpoolDelivery to remove the entry. Completing the same proof twice fails with StaleToken.
터미널 창
dotnet build "$Examples\dotnet\PDComm.GemSpoolingDemo\PDComm.GemSpoolingDemo.csproj" -c Release "-p:PDCommSdk=$Sdk" --artifacts-path "$Root\build\managed"

Expected result: Build succeeded. and 0 Error(s).

The fifth argument is the spool snapshot path. Pass a new file path to the equipment and - to the host, which keeps no spool of its own.

터미널 창
# Terminal A: equipment
dotnet "$Root\build\managed\bin\PDComm.GemSpoolingDemo\release\PDComm.GemSpoolingDemo.dll" $Native equipment passive 127.0.0.1:0 "$Root\equipment.spool"
# Terminal B: host
dotnet "$Root\build\managed\bin\PDComm.GemSpoolingDemo\release\PDComm.GemSpoolingDemo.dll" $Native host active 127.0.0.1:<port> -

Expected result: both sides print their PASS line and exit with code 0. The equipment’s folder now contains equipment.spool and the lock file equipment.spool.lock.

PASS host active DOTNET SPOOLING PEER
LISTEN 127.0.0.1:54615
PASS equipment passive DOTNET SPOOLING PEER

Before it starts (Demo.ConfigureEquipment), the equipment sets up its spool:

  • Catalog: S3F1, S3F3, S5F1, S6F11.
  • Policy: spooling enabled, at most two entries per transmission (MaxTransmit = 2), new messages dropped when full (DiscardNew).
  • Selection: stream 3, functions 1 and 3.
  • Because communication hasn’t started, SyncSpool(100) activates spooling, and the equipment captures S3F1 (W-bit, body "BOOT-A") and S3F3 (no W-bit, no body) as entries 1 and 2.

Once communication is up, the host runs 18 steps (Demo.SendHostStep). The equipment answers each spooling request with RespondSpool.

Step Host request Result
0 S2F59 current selection Stream 3, functions 1 and 3
1 S2F43: S1F1, S4F1, S3F2 RSPACK 1 with STRACK 1 (stream 1 can’t spool), 2 (unknown stream 4) and 4 (secondary function 2). The selection stays as it was.
2 S2F43: all of stream 3, S5F1, S6F11 RSPACK 0
3–6 Define report, link event, enable event, enable alarm 7 All 0
7 S1F3 status variable 11 Value 10. After replying, the equipment captures an event (CEID 10) and alarm 7 as entries 3 and 4.
8 S6F23 transmit RSDA 0. The equipment sends entry 1 (S3F1) and the host answers with S3F2. Entry 2 (S3F3) completes with a Written proof. MaxTransmit = 2 ends the run.
9 S1F3 status variable 11 Value 99. The equipment confirms that two entries remain.
10 S6F23 transmit RSDA 0. Entries 3 (S6F11) and 4 (S5F1) are delivered, and the host answers with ACKC6 0 and ACKC5 0.
11 S1F3 Confirms the spool is empty.
12, 13 S6F23 transmit, then purge Both RSDA 2 (nothing spooled)
14, 15 Empty S2F43, then S2F59 Confirms the selection is empty.
16, 17 S2F43 again, then S1F3 Restores the selection and finishes.

For every delivery, the equipment checks the reply body and removes the entry with CompleteSpoolDelivery(result, true). It also confirms that completing the same result a second time fails with StaleToken.

When the host disconnects, the equipment notices that communication is down (Demo.CaptureFinal) and captures S3F1 and S3F3 again as entries 5 and 6. These two entries stay in the snapshot file.

Full source of PDComm.GemSpoolingDemo/Program.cs
PDComm.GemSpoolingDemo/Program.cs
using PDComm;
if (args.Length < 5 || !Path.IsPathFullyQualified(args[0]) ||
args[1] is not ("host" or "equipment") || args[2] is not ("active" or "passive"))
{
Console.Error.WriteLine("Usage: PDComm.GemSpoolingDemo <absolute-native-library-path> <host|equipment> <active|passive> <numeric-address> <snapshot-path>");
return 2;
}
PdcommRuntime.Initialize(args[0]);
if (args.Length == 6)
{
if (args[5] != "simulator")
throw new ArgumentException("The optional profile must be simulator.");
return SimulatorProfile.Run(args[1], args[2], args[3], args[4]);
}
bool isHost = args[1] == "host";
HsmsMode mode = args[2] == "active" ? HsmsMode.Active : HsmsMode.Passive;
using GemEndpoint endpoint = isHost
? GemHost.Create(args[3], Demo.Transport(mode), Demo.Host)
: GemEquipment.Create(args[3], Demo.Transport(mode), Demo.Equipment);
if (endpoint is GemHost host) host.ConfigureSpoolHost(Demo.HostSpool);
else Demo.ConfigureEquipment((GemEquipment)endpoint, args[4] == "-" ? null : args[4]);
endpoint.Start();
if (mode == HsmsMode.Passive)
{
Console.WriteLine($"LISTEN {endpoint.GetLocalAddress()}");
Console.Out.Flush();
}
int step = 0, served = 0, deliveries = 0, barriers = 0;
int stopDelay = -1;
bool began = false, communicating = false, pendingActive = false, stopping = false;
HsmsToken pending = default;
DateTime deadline = DateTime.UtcNow.AddSeconds(45);
ulong clock = 300;
while (DateTime.UtcNow < deadline)
{
endpoint.Poll();
HsmsEvent? transportEvent;
while ((transportEvent = endpoint.NextEvent()) is not null)
{
using (transportEvent)
using (GemResult result = endpoint.HandleEvent(transportEvent))
{
HsmsEventInfo wire = transportEvent.GetInfo();
GemResultInfo info = result.GetInfo();
if (!isHost) ((GemEquipment)endpoint).SyncSpool(clock++);
if (isHost && Demo.IsGenericPrimary(wire))
{
Demo.VerifyGenericPrimary(transportEvent, deliveries);
if (deliveries == 0) Demo.ReplyGeneric(endpoint, transportEvent);
deliveries++;
}
else if (isHost && info.Kind == GemResultKind.Primary &&
info.Outcome is GemOutcome.EventReceived or GemOutcome.AlarmReceived)
{
Demo.VerifyTypedPrimary((GemHost)endpoint, result, deliveries);
deliveries++;
}
else if (!isHost && info.Kind == GemResultKind.Primary &&
info.Outcome == GemOutcome.SpoolReceived)
{
Demo.VerifyEquipmentRequest(result, served);
PdcommStatus status = ((GemEquipment)endpoint).RespondSpool(result);
if (status != PdcommStatus.Ok)
throw new InvalidOperationException($"Unexpected spool response status {status}.");
served++;
}
else if (!isHost && info.Kind == GemResultKind.Response &&
info.RequestKind == GemRequestKind.SpoolDelivery)
{
Demo.VerifyDelivery(result, deliveries);
GemSpoolWriteResult completed = ((GemEquipment)endpoint).CompleteSpoolDelivery(result, true);
if (!completed.Applied || completed.Mutation != GemSpoolMutation.Removed ||
completed.AffectedCount != 1)
throw new InvalidOperationException("The spooled delivery was not removed.");
try
{
((GemEquipment)endpoint).CompleteSpoolDelivery(result, true);
throw new InvalidOperationException("A consumed delivery proof was accepted.");
}
catch (PdcommException error) when (error.Status == PdcommStatus.StaleToken)
{
}
deliveries++;
}
else if (isHost && info.Kind == GemResultKind.Response)
{
if (!pendingActive || info.Token != pending)
throw new InvalidOperationException("The spool response token did not match.");
pendingActive = false;
if (info.RequestKind == GemRequestKind.Establish)
{
if (info.Outcome != GemOutcome.CommunicationEstablished)
throw new InvalidOperationException("GEM communication was rejected.");
communicating = true;
}
else
{
Demo.VerifyHostResponse(result, step);
step++;
}
}
else if (!isHost && info.Outcome == GemOutcome.StatusReplied)
{
barriers++;
if (barriers == 1) Demo.CaptureTyped((GemEquipment)endpoint);
else if (barriers is 2 or 3)
Demo.VerifyEquipmentState((GemEquipment)endpoint, barriers == 2 ? 2u : 0u);
else if (barriers != 4)
throw new InvalidOperationException("The host sent an extra completion barrier.");
}
else if (info.Kind is GemResultKind.Failed or GemResultKind.InvalidResponse ||
(info.Kind == GemResultKind.Unhandled && !Demo.IsGenericPrimary(wire)))
{
throw new InvalidOperationException(
$"Spooling protocol failed: kind={info.Kind}, outcome={info.Outcome}, request={info.RequestKind}.");
}
}
}
GemState gem = endpoint.GetState();
if (gem.Communication == GemCommunicationState.Communicating) communicating = true;
HsmsState transport = endpoint.GetTransportState();
if (isHost && !began && transport.State == HsmsConnectionState.Selected)
{
pending = endpoint.BeginCommunication();
began = true;
pendingActive = true;
}
if (isHost && communicating && step < Demo.HostSteps &&
Demo.MinimumDeliveries(step) <= deliveries && !pendingActive)
{
pending = Demo.SendHostStep((GemHost)endpoint, step);
pendingActive = true;
}
if (!isHost && communicating)
{
GemEquipment equipment = (GemEquipment)endpoint;
GemSpoolState state = equipment.GetSpoolState();
if (state.Transmitting && !state.DeliveryPending) equipment.SendNextSpool();
}
if (isHost && step == Demo.HostSteps && deliveries == Demo.DeliveryCount && stopDelay < 0)
stopDelay = 32;
if (isHost && stopDelay == 0 && !stopping)
{
endpoint.Stop();
stopping = true;
}
else if (isHost && stopDelay > 0)
stopDelay--;
if (!isHost && communicating && transport.State != HsmsConnectionState.Selected && !stopping)
{
if (served != Demo.RequestCount || deliveries != Demo.DeliveryCount || barriers != 4)
throw new InvalidOperationException(
$"The host disconnected early: requests={served}, deliveries={deliveries}, barriers={barriers}.");
Demo.CaptureFinal((GemEquipment)endpoint);
endpoint.Stop();
stopping = true;
}
if (stopping && endpoint.GetTransportState().Closed)
{
if (isHost && (step != Demo.HostSteps || deliveries != Demo.DeliveryCount))
throw new InvalidOperationException("The host stopped before the spool exchange completed.");
Console.WriteLine($"PASS {args[1]} {args[2]} DOTNET SPOOLING PEER");
return 0;
}
Thread.Sleep(1);
}
throw new TimeoutException(
$"Spooling peer timed out: role={args[1]}, step={step}, served={served}, deliveries={deliveries}, barriers={barriers}.");
internal static class Demo
{
internal const int HostSteps = 18;
internal const int DeliveryCount = 4;
internal const int RequestCount = 10;
internal static readonly SecsLimits Limits = new(4096, 4096, 256, 16);
internal static readonly GemSpoolOptions Spool = new() { MaxMessages = 8, MaxBodyBytes = 1024, MaxTotalBodyBytes = 4096, MaxStreams = 4, MaxFunctionsPerStream = 8, MaxCatalogEntries = 8, MaxSerializedBytes = 8192 };
internal static readonly GemSpoolHostOptions HostSpool = new() { MaxStreams = 4, MaxFunctionsPerStream = 8, MaxPendingItems = 16 };
internal static readonly GemHostOptions Host = new() { MaxPendingIds = 64, ItemLimits = Limits };
internal static readonly GemEquipmentOptions Equipment = new() { Model = "PDComm", SoftwareRevision = "0.1.0", InitialControl = GemControlState.HostOffline, OnlineMode = GemOnlineMode.Remote, MaxVariables = 8, MaxMetadataBytes = 1024, MaxValueBytes = 4096, ItemLimits = Limits };
private static readonly GemSpoolMessageKey[] Catalog = [new(3, 1), new(3, 3), new(5, 1), new(6, 11)];
internal static HsmsOptions Transport(HsmsMode mode) => new() { Mode = mode, SessionId = 7, InitialSystemBytes = 1, T3Milliseconds = 2000, T5Milliseconds = 20, T6Milliseconds = 200, T7Milliseconds = 1000, T8Milliseconds = 500, ConnectTimeoutMilliseconds = 50, ShutdownTimeoutMilliseconds = 100, WriteTimeoutMilliseconds = 1000, MaxFrameBytes = 4110, MaxEvents = 128, MaxQueuedBytes = 65536, MaxWriteFrames = 64, MaxWriteBytes = 65536, MaxIoChunk = 3, MaxApplicationEvents = 128, MaxApplicationBytes = 65536, PollBudget = 32, ItemLimits = Limits };
internal static void ConfigureEquipment(GemEquipment equipment, string? path)
{
using SecsItem occurrence = SecsItem.FromU4([10], Limits);
equipment.RegisterStatus(11, "Occurrence", "count", occurrence);
equipment.ConfigureReports(new GemReportOptions { MaxEvents = 4, MaxReports = 4, MaxVariablesPerReport = 8, MaxReportsPerEvent = 4, MaxTotalVariables = 16, MaxTotalLinks = 16 }, [10]);
equipment.ConfigureAlarms(new GemAlarmOptions { MaxAlarms = 4, MaxTotalTextBytes = 128, MaxTextBytes = 120 }, [new GemAlarmDefinition(7, 2, "Pressure")]);
equipment.ConfigureSpooling(Spool, Catalog, path);
equipment.ConfigureLocalSpooling(
new GemSpoolPolicy { Overflow = GemSpoolOverflow.DiscardNew, MaxTransmit = 2, Enabled = true },
InitialSelections());
equipment.SyncSpool(100);
using SecsItem body = SecsItem.FromAscii("BOOT-A", Limits);
RequireCapture(equipment.CaptureSpool(3, 1, true, body, 101), 1);
RequireCapture(equipment.CaptureSpool(3, 3, false, null, 102), 2);
}
internal static bool IsGenericPrimary(HsmsEventInfo info) =>
info.Kind == HsmsEventKind.Received && !info.IsReply &&
info.Header is { Stream: 3, Function: 1 or 3 };
internal static void VerifyGenericPrimary(HsmsEvent value, int ordinal)
{
if (ordinal is < 0 or > 1)
throw new InvalidOperationException("The equipment sent an extra generic spool delivery.");
HsmsEventInfo info = value.GetInfo();
HsmsHeader header = info.Header ?? throw new InvalidOperationException("The generic delivery had no header.");
byte function = ordinal == 0 ? (byte)1 : (byte)3;
if (header.Function != function || header.ReplyExpected != (ordinal == 0))
throw new InvalidOperationException("The generic spool delivery header differed.");
using SecsItem? body = value.DecodeBody(Limits);
if (ordinal == 0 && body?.GetAscii() != "BOOT-A")
throw new InvalidOperationException("The generic spool body differed.");
if (ordinal == 1 && body is not null)
throw new InvalidOperationException("The bodyless spool delivery gained a body.");
}
internal static void ReplyGeneric(GemEndpoint endpoint, HsmsEvent primary)
{
using SecsItem acknowledgement = SecsItem.FromU1([0], Limits);
using SecsItem text = SecsItem.FromAscii("ACCEPT", Limits);
using SecsItem body = SecsItem.CreateList([acknowledgement, text], Limits);
endpoint.ReplyRaw(primary, 3, 2, false, body.Encode());
}
internal static void VerifyTypedPrimary(GemHost host, GemResult result, int ordinal)
{
if (ordinal == 2)
{
GemEventInfo info = result.GetEventInfo();
GemEventReport report = result.GetEventReport(0);
using SecsItem value = result.GetEventValue(0, 0);
if (info.DataId != 1000 || info.EventId != 10 || info.ReportCount != 1 ||
report.ReportId != 100 || report.ValueCount != 1 ||
!value.GetU4().SequenceEqual(new uint[] { 10 }))
throw new InvalidOperationException("The spooled event report differed.");
host.AcknowledgeEvent(result, 0);
}
else if (ordinal == 3)
{
GemAlarmResultInfo info = result.GetAlarmInfo();
GemAlarmValue alarm = result.GetAlarmAt(0);
if (!info.IsReport || !info.ReplyExpected || info.Count != 1 ||
alarm.Info.Id != 7 || alarm.Info.Category != 2 || !alarm.Info.Active ||
alarm.Text != "Pressure")
throw new InvalidOperationException("The spooled alarm report differed.");
host.AcknowledgeAlarm(result, 0);
}
else throw new InvalidOperationException("The typed delivery order differed.");
}
internal static void VerifyEquipmentRequest(GemResult result, int ordinal)
{
int[] steps = [0, 1, 2, 8, 10, 12, 13, 14, 15, 16];
if ((uint)ordinal >= steps.Length)
throw new InvalidOperationException("The host sent an extra spool request.");
int step = steps[ordinal];
GemResultInfo generic = result.GetInfo();
GemSpoolInfo info = result.GetSpoolInfo();
if (!info.IsRequest || info.Responded || generic.RequestKind != RequestKind(step))
throw new InvalidOperationException("The incoming spool request differed.");
if (generic.RequestKind == GemRequestKind.SpoolConfigure &&
info.SelectionCount != (nuint)SelectionsForStep(step).Length)
throw new InvalidOperationException("The incoming spool selection count differed.");
if (generic.RequestKind == GemRequestKind.SpoolControl &&
info.Command != (step == 13 ? GemSpoolCommand.Purge : GemSpoolCommand.Transmit))
throw new InvalidOperationException("The incoming spool command differed.");
}
internal static void VerifyDelivery(GemResult result, int ordinal)
{
if ((uint)ordinal >= DeliveryCount)
throw new InvalidOperationException("The host acknowledged an extra spool delivery.");
GemResultInfo generic = result.GetInfo();
GemSpoolInfo info = result.GetSpoolInfo();
GemOutcome expected = ordinal == 1 ? GemOutcome.SpoolDeliveryWritten : GemOutcome.SpoolDeliveryResponse;
GemSpoolDeliveryProof proof = ordinal == 1 ? GemSpoolDeliveryProof.Written : GemSpoolDeliveryProof.Response;
if (generic.Outcome != expected || info.DeliveryProof != proof ||
info.EntryId != (ulong)ordinal + 1 || info.DeliveryAttempt != (ulong)ordinal + 1 ||
info.DeliveryCompleted)
throw new InvalidOperationException("The spool delivery proof differed.");
if (ordinal == 0)
{
using SecsItem? body = result.GetBody();
using SecsItem acknowledgement = SecsItem.FromU1([0], Limits);
using SecsItem text = SecsItem.FromAscii("ACCEPT", Limits);
using SecsItem expectedBody = SecsItem.CreateList([acknowledgement, text], Limits);
if (body is null || !body.Encode().SequenceEqual(expectedBody.Encode()))
throw new InvalidOperationException("The generic spool response body differed.");
}
else if (ordinal >= 2)
{
using SecsItem? body = result.GetBody();
if (body is null || !body.GetBinary().SequenceEqual(new byte[] { 0 }))
throw new InvalidOperationException("The typed spool acknowledgement differed.");
}
}
internal static int MinimumDeliveries(int step) => step is 9 or 10 ? 2 : step >= 11 ? 4 : 0;
internal static HsmsToken SendHostStep(GemHost host, int step) => step switch
{
0 or 15 => host.QueryRemoteSpooling(),
1 or 2 or 14 or 16 => host.RequestSpoolConfiguration(SelectionsForStep(step)),
3 => host.DefineReports(1, [new GemReportSpec(100, new uint[] { 11 })]),
4 => host.LinkReports(2, [new GemLinkSpec(10, new uint[] { 100 })]),
5 => host.EnableEvents(true, [10]),
6 => host.EnableAlarm(7, true, true),
7 or 9 or 11 or 17 => host.RequestStatusValues([11]),
8 or 10 or 12 => host.RequestSpooledData(GemSpoolCommand.Transmit),
13 => host.RequestSpooledData(GemSpoolCommand.Purge),
_ => throw new ArgumentOutOfRangeException(nameof(step)),
};
internal static void VerifyHostResponse(GemResult result, int step)
{
GemResultInfo generic = result.GetInfo();
if (generic.RequestKind != RequestKind(step))
throw new InvalidOperationException("The spool response kind differed.");
if (step is 3 or 4 or 5 or 6)
{
if (generic.Acknowledgement != 0)
throw new InvalidOperationException("A configuration request was rejected.");
return;
}
if (step is 7 or 9 or 11 or 17)
{
using GemStatusValue status = result.GetStatusValue(0);
uint expected = step == 7 ? 10u : 99u;
if (status.Id != 11 || !status.Value.GetU4().SequenceEqual(new uint[] { expected }))
throw new InvalidOperationException("The barrier status value differed.");
return;
}
GemSpoolInfo info = result.GetSpoolInfo();
if (step is 0 or 15)
{
GemSpoolSelection[] expected = step == 0 ? InitialSelections() : [];
if (generic.Outcome != GemOutcome.SpoolConfigurationData ||
info.SelectionCount != (nuint)expected.Length)
throw new InvalidOperationException("The spool query result differed.");
VerifyResultSelections(result, expected);
}
else if (step == 1)
{
byte[] streams = [1, 4, 3];
byte[] acknowledgements = [1, 2, 4];
if (generic.Outcome != GemOutcome.SpoolConfigurationAck ||
info.Acknowledgement != 1 || info.ErrorCount != 3)
throw new InvalidOperationException("The invalid spool selection was not rejected.");
for (nuint index = 0; index < info.ErrorCount; index++)
{
using GemSpoolResultError error = result.GetSpoolError(index);
if (error.Stream != streams[index] || error.Acknowledgement != acknowledgements[index])
throw new InvalidOperationException("The spool selection error differed.");
}
}
else if (step is 2 or 14 or 16)
{
if (generic.Outcome != GemOutcome.SpoolConfigurationAck ||
info.Acknowledgement != 0 || info.ErrorCount != 0 || info.Applied)
throw new InvalidOperationException("The valid spool selection acknowledgement differed.");
}
else
{
byte expected = step is 12 or 13 ? (byte)2 : (byte)0;
if (generic.Outcome != GemOutcome.SpoolControlAck || info.Acknowledgement != expected)
throw new InvalidOperationException("The spool control acknowledgement differed.");
}
}
internal static void CaptureTyped(GemEquipment equipment)
{
RequireCapture(equipment.CaptureSpoolEvent(1000, 10, [], 200), 3);
GemAlarmChange set = equipment.ObserveAlarm(7, true);
if (!set.Changed || !set.ReportingEnabled)
throw new InvalidOperationException("The alarm did not become reportable.");
RequireCapture(equipment.CaptureSpoolAlarm(7, true, 201), 4);
using SecsItem value = SecsItem.FromU4([99], Limits);
equipment.UpdateStatus(11, value);
equipment.ObserveAlarm(7, false);
}
internal static void VerifyEquipmentState(GemEquipment equipment, nuint expectedCount)
{
GemSpoolState state = equipment.GetSpoolState();
if (state.MessageCount != expectedCount || !state.Synchronized ||
state.DeliveryPending || state.DequeuePending)
throw new InvalidOperationException("The equipment spool state differed.");
}
internal static void CaptureFinal(GemEquipment equipment)
{
equipment.SyncSpool(900);
using SecsItem empty = SecsItem.FromBinary([], Limits);
RequireCapture(equipment.CaptureSpool(3, 1, true, empty, 901), 5);
RequireCapture(equipment.CaptureSpool(3, 3, false, null, 902), 6);
}
private static void RequireCapture(GemSpoolWriteResult result, ulong entryId)
{
if (!result.Applied || !result.Selected || result.EntryId != entryId)
throw new InvalidOperationException($"Spool entry {entryId} was not captured.");
}
private static GemRequestKind RequestKind(int step) => step switch
{
0 or 15 => GemRequestKind.SpoolQuery,
1 or 2 or 14 or 16 => GemRequestKind.SpoolConfigure,
8 or 10 or 12 or 13 => GemRequestKind.SpoolControl,
3 => GemRequestKind.DefineReports,
4 => GemRequestKind.LinkReports,
5 => GemRequestKind.EnableEvents,
6 => GemRequestKind.EnableAlarm,
_ => GemRequestKind.StatusValues,
};
private static GemSpoolSelection[] InitialSelections() =>
[new GemSpoolSelection(3, new byte[] { 1, 3 })];
private static GemSpoolSelection[] ValidSelections() =>
[new GemSpoolSelection(3, Array.Empty<byte>()),
new GemSpoolSelection(5, new byte[] { 1 }),
new GemSpoolSelection(6, new byte[] { 11 })];
private static GemSpoolSelection[] SelectionsForStep(int step) => step switch
{
1 => [new GemSpoolSelection(1, new byte[] { 1 }),
new GemSpoolSelection(4, new byte[] { 1 }),
new GemSpoolSelection(3, new byte[] { 2 })],
14 => [],
2 or 16 => ValidSelections(),
_ => throw new ArgumentOutOfRangeException(nameof(step)),
};
private static void VerifyResultSelections(GemResult result, GemSpoolSelection[] expected)
{
for (nuint index = 0; index < (nuint)expected.Length; index++)
{
using GemSpoolResultSelection actual = result.GetSpoolSelection(index);
GemSpoolSelection expectedSelection = expected[checked((int)index)];
if (actual.Stream != expectedSelection.Stream ||
!actual.Functions.GetU1().SequenceEqual(expectedSelection.Functions.ToArray()))
throw new InvalidOperationException("The returned spool selection differed.");
}
}
}

Run the equipment again with the same file:

터미널 창
dotnet "$PDCOMM_ROOT/build/managed/bin/PDComm.GemSpoolingDemo/release/PDComm.GemSpoolingDemo.dll" "$PDCOMM_NATIVE" equipment passive 127.0.0.1:0 "$PDCOMM_ROOT/equipment.spool"

Expected result: the equipment stops right away with this exception:

Unhandled exception. System.InvalidOperationException: Spool entry 1 was not captured.

That’s persistence at work, not a bug. The file still holds entries 5 and 6 and the counters from the previous run, so new entries no longer start at number 1, and the example checks entry numbers against an empty spool. To run the example again, pass a new path or delete equipment.spool and equipment.spool.lock.

A file-backed spool follows these rules:

  • While the endpoint exists, it holds an exclusive lock file next to the snapshot. Another process that opens the same file gets Backpressure.
  • Every change writes the complete state to a temporary file, renames it over the snapshot, and synchronizes the folder.
  • Opening the file with a catalog that differs from the stored one fails with InvalidData. The file is never replaced or migrated silently.
  • When persistence can’t be confirmed, the result is PdcommStatus.SpoolUnconfirmed. If RespondSpool returns it, the reply is held. Call GemEquipment.ConfirmSpoolReply and then call RespondSpool again with the same result.

The Qt example spooling.cpp in the examples bundle follows the same sequence (PDCOMM_SPOOL_UNCONFIRMED, confirmSpoolReply, respondSpool) on a Qt worker thread. Because it shares scenario definitions with PDComm’s internal tests, it doesn’t build from the SDK alone and isn’t reproduced here. For the Qt threading model, see commands.cpp in Remote commands.

Use the two-window setup from the simulator GUI guide. Prepare the equipment window first.

  1. In Reports & Events → Catalog, click Load example and Use on next start. This prepares the CEID 10 event.

  2. In Spooling → Configuration, click Load example. S5F1 and S6F11 fill the catalog and the selection, and Enable local spooling is turned on. Click Use on next start.

    Equipment window with the example catalog and the actual session configuration in the Configuration tab of the Spooling workspaceEquipment window with the example catalog and the actual session configuration in the Configuration tab of the Spooling workspace
  3. Start the equipment session before you connect the host. Without communication, spooling activates.

  4. In the Queue tab, enter DATAID 1 and CEID 10, and click Capture event. The queue gets an entry with stream 6, function 11 and the W-bit set.

  5. In the host window, start the session and click Establish communication.

  6. In the host window, open Spooling → Controls & Delivery, choose Transmit, and click Submit data control.

  7. In the equipment window, open Controls & Delivery, select the pending control, and click Respond to selected control. Then click Send next.

  8. In the host window, open Reports & Events → Traffic → Events, select the received event, and click Acknowledge with ACKC6 0.

  9. In the equipment window, select the pending delivery and click Accept W1. The queue empties and spooling becomes inactive.

Symptom Cause Fix
Spool entry 1 was not captured. The example reopened a snapshot from an earlier run. Use a new path, or delete the snapshot and its .lock file.
ConfigureSpooling fails with Backpressure Another process holds the lock on the same snapshot. Stop that process or use another file.
ConfigureSpooling fails with InvalidData The file is corrupt, or its catalog differs from the one you passed. Match the catalog to the file, or start with a new file.
ConfigureSpooling fails with InvalidArgument The catalog of a new spool is empty or has an invalid or repeated entry. Or the path has no file name, or you passed a path with GemSpoolOptions.MaxSerializedBytes below 144. Check the catalog entries, and pass a path that ends in a file name with a MaxSerializedBytes of at least 144.
CaptureSpool fails with InvalidState Spooling isn’t configured, or it isn’t synchronized with SyncSpool. Call SyncSpool after handling events, then capture again.
CaptureSpool fails with InvalidArgument The message can’t be spooled, for example stream 1 or an even (secondary) function. Capture only primary messages of streams 2 to 127.
The capture result has Selected set to false Spooling is inactive, or the message isn’t in the catalog and the selection. Check that communication is down and that the selection includes the message.
S6F24 carries RSDA 1 An earlier transmission or delivery hasn’t finished. Complete the pending delivery, then ask again.
S6F24 carries RSDA 2 Nothing is spooled. This is a normal reply.
SendNextSpool fails with OutOfRange The spool is empty, which ends the run. There is nothing left to send.
SendNextSpool fails with Backpressure A delivery is in flight or waiting for removal. Finish it with CompleteSpoolDelivery.
RespondSpool returns SpoolUnconfirmed The snapshot write couldn’t be confirmed. Call ConfirmSpoolReply, then RespondSpool again with the same result.
A host request fails with InvalidState Host spooling isn’t configured, or communication isn’t up yet. Call ConfigureSpoolHost before you start, and send requests after communication is established.
  • The example exits on its own when the script finishes.
  • Delete the equipment.spool and equipment.spool.lock files you created.
  • In the simulator, click Stop in both windows. The simulator keeps its spool in memory only.