Debug Adapter¶
Debug-adapter lookup for the truST DAP bridge, safe points, variable visibility, hot-reload interaction, runtime-control boundaries, and exact request/response semantics.
Related: Runtime Semantics, LSP
Status: Draft
Scope¶
This specification defines the expected behavior of the Structured Text (ST) debug adapter and runtime debug hooks for VS Code using the Debug Adapter Protocol (DAP). It covers breakpoints, run control, stepping, source mapping, and multi-file navigation.
This document is implementation-agnostic but aligns with the DAP definitions in
Debug Adapter Protocol specification (see References).
References (Normative)¶
- DAP base and request/response/event shapes:
Debug Adapter Protocol specification Request,Response,EventInitializeRequest,InitializedEventLaunchRequestAttachRequestSetBreakpointsRequest,Breakpoint,BreakpointLocationsRequestContinueRequest,PauseRequest,NextRequest,StepInRequest,StepOutRequestStoppedEventStackTraceRequest,ScopesRequest,VariablesRequest,EvaluateRequestDisconnectRequest,TerminateRequest
Terms¶
- Adapter:
trust-debugprocess handling DAP requests. - Runtime:
trust-runtimeprocess executing ST code. - Statement: A single executable ST statement with a source location.
- Location:
(file_id, start_offset, end_offset)in source text, a half-open byte range. A runtime without source text reconstructs it from the module's line table (specification 12 §6.13). - Task: IEC task representing a cyclic execution unit.
Source Mapping¶
1) Every executable statement must be assigned a location at the first non-trivia token in
its syntax node. The location span covers the full statement text range.
2) Each source file loaded in a debug session has a unique file_id and is registered in the
adapter with its path and, when the tool has it, its full text. Every statement's position
comes from the program image's statement table (specification 44 §5.9), which carries each
statement's file, POU, and start and end line and column. The text serves
presentation only: a runtime that holds the text projects those positions to byte offsets, and
a runtime without the text reports line and column.
3) The adapter converts runtime locations to (line, column) for DAP using 1-based coordinates
when linesStartAt1 / columnsStartAt1 are true.
4) A breakpoint source path relative to the selected project resolves against
that project root and binds to the already registered source/file ID. It
must not resolve outside the project root.
One resolver on every runtime (owner, 18 October 2026). Breakpoints and stepping resolve through the program image's statement table (specification 44 §5.9) on trust-runtime and on a chip, and on any other runtime of the shared services. A runtime needs no ST source text, no compiler and no HIR to resolve them: the image carries the statement spans and the file labels. The rules below ("Breakpoint evaluation, source resolution, and hook contract", "Stepping Semantics", "Safe Points") are unchanged; only their input changes, from recompiled sources to the artifact.
Moving to that table changes no answer. For a given program, breakpoint resolution, step stops, stop
locations, stack-frame locations and breakpoint-location listings return exactly what the
source-based resolver returned before, including the case where a breakpoint on an IF line
resolves to the IF statement. The body statements of that IF start inside its span, so the
start-inside rule of the hook contract matches them too, but the cycle guard allows one stop per
breakpoint per scan, at the first matching statement, which is the IF statement. A difference
from these answers is a defect in the statement table, not a new behaviour
(owner, 18 October 2026). A release image has no statement hooks to hold at and is not
debuggable (specification 44 §13.2).
A file's identity in an answer is its label, the source path relative to the project root with /
separators, or the fallback label file_<file_id> of specification 12 §7.5.1.4. The runtime's file
index is internal and is never part of an answer a client depends on, so numbering the files in the
table changes nothing a user or tool sees.
Breakpoints¶
SetBreakpoints¶
SetBreakpointsRequestreplaces all breakpoints for the given source.- Passing an empty list clears all breakpoints for that source in both adapter and runtime.
- Breakpoints are statement-based and resolved to the first statement whose location is at or
after the requested
(line, column). - A request names its source by a path. A runtime without source text matches the path to the file labels of the program image (specification 44 §5.3; specification 11 §6.13.3).
- Column snapping:
- If the client omits a column, the adapter snaps to the first non-whitespace column on that line.
- If a column is provided but points before the first non-whitespace column, the adapter snaps forward to that first column.
Breakpoint Locations¶
BreakpointLocationsRequestreturns the set of valid statement start positions in the requested range.
Cyclic Tasks¶
- In cyclic tasks, a breakpoint in a statement that executes every scan will stop every scan until the breakpoint is cleared or a hit condition/condition filters it.
- Users should use hit counts or conditional breakpoints for one-shot behavior.
- A breakpoint installed after launch, including while cyclic execution is already running, applies at the next matching statement boundary. It does not retroactively stop a statement or scan that completed before installation.
Run Control¶
Continue¶
ContinueRequestresumes all threads.- Any pending pause request is cleared.
- A
StoppedEventis emitted only if a breakpoint, step, or pause condition is hit after resuming.
Hold duration (owner, 18 October 2026). A debugger hold — a breakpoint stop, a debug-mode pause
or a completed step — lasts until a continue or a stop, on every runtime, trust-runtime and a chip
alike, however long the operator waits. Neither a timeout, the cycle watchdog, a missed deadline, a
full command queue, a detach, an attach nor a configuration change releases it. The one end that is
not a continue is an explicit stop, so a breakpoint, pause or step never delays a stop
(specification 11 §6.9). A chip's hold loop serves the control connection and feeds the hardware
watchdog while it waits, so a long hold neither resets the device nor loses a queued request
(specification 34 STATE-07). trust-runtime's hold loop does so: it watches the stop request and releases the hold when one
arrives (owner, 18 October 2026; crates/trust-runtime/tests/debugger_hold_stop.rs).
Pause¶
PauseRequestis honored only if execution is currently running.- The adapter must respond to the request before emitting
StoppedEventwith reasonpause. - If already paused, the adapter returns success and does not emit another pause event.
- A statement-boundary pause suspends the current runtime cycle. Operator dwell time while stopped is excluded from the cycle watchdog and output-commit deadlines; resume continues with the remaining active-execution budget.
- No new scan or output commit starts solely because the debugger is waiting.
Pause does not apply safe state. An independent stop, fault, or active-execution
watchdog breach retains its normal runtime semantics. See
docs/specs/11-runtime-engine.md#debug-and-resource-pause-interaction.
Stop on Entry¶
LaunchRequestwithstopOnEntry=trueresults in a pause as soon as the first statement boundary is reached.
Attach / Detach (Production)¶
AttachRequestconnects to a running runtime instance.- Attach must not restart or reload the runtime.
- Attach must observe the existing execution state (running/paused/faulted).
- If attach occurs while the runtime is paused, the adapter should immediately emit a
StoppedEventreflecting the paused state. DisconnectRequest/TerminateRequestmust not alter runtime execution unless the user explicitly requests termination.
Attach arguments (adapter-specific):
- endpoint (optional): control endpoint, e.g. unix:///run/plc/control.sock or
tcp://127.0.0.1:9000. Without it the adapter resolves the endpoint of the project named by
runtimeRoot in the order of specification 11 §6.8.0a (endpoint file, configured endpoint,
platform default).
- runtimeRoot (optional): the project folder, used for that resolution and for the project's
token.
- authToken (optional): control auth token (same value used by trust-runtime ctl), with the
credential precedence of specification 11 §6.8.1.
Without endpoint and runtimeRoot, attach fails with attach requires endpoint or runtimeRoot.
When nothing listens at an endpoint that came from the endpoint file or the platform default,
attach reports that the runtime is not running (specification 11 §6.8.0a).
Attach requires runtime.control.debug_enabled=true. If disabled, the adapter must report an
error and remain disconnected.
The runtime control role matrix in
docs/specs/11-runtime-engine.md#runtime-control-authorization governs attach
operations. Debug reads require Viewer; pause/resume require Operator; step,
breakpoint mutation, evaluation, write, force, and release require Engineer.
Viewer and Operator mutation attempts must fail before queued writes or force
state changes. Only Admin may enable the debug surface or change control mode;
Engineer authority permits use of an already enabled surface, not activation of
that surface. Runtime role denials carry the stable control error code
insufficient_role; the adapter must preserve that authorization category when
reporting the failed request.
Every session, a launch's and an attach's alike, writes, forces and releases variables through the
runtime it is connected to, as "setVariable and setExpression" below says (the owner, 26 October
2026: "did you run a program, simulate and force values etc? did you test all workflows?"). The Live
Values I/O operations stIoWrite, stIoForce, and stIoRelease forward to the runtime control
endpoint, and every write surfaces the runtime's authorization and capability errors honestly.
For a managed local debug session, Live Values writes and forces to a declared
REAL or LREAL address parse the submitted text as that IEC floating-point
type. The adapter accepts only finite semantic values. It rejects NaN,
positive infinity, negative infinity, values that overflow the declared type,
and integer encodings that attempt to supply non-finite IEEE bit patterns. A
rejection returns a failed DAP response before the value is queued, forced, or
written to the process image; the previous value and force state remain
unchanged. Attach-mode validation remains governed by the runtime control
endpoint and is not covered by this managed-session rule.
Live Values Mutation Lifetime¶
- A successful write is queued for the next scan boundary and is consumed once.
- Managed-local I/O writes and forces also update the authoritative process image immediately after checked admission, preserving the existing I/O inspection response. This image update is distinct from applying a PLC input binding or delivering a physical output: ordered input writes are reapplied after physical sampling at the next scan, and output forces reach drivers at the normal output-publication boundary. A rejected image update shall not install a queued write or a force. Detached paused I/O displays remain presentation; they do not copy images back into the installed owner.
- Queue acceptance and a detached paused-display edit are not proof that the
live destination was changed. At application, the runtime rechecks the
destination lifetime and type. A retired invocation-local destination is
rejected with a
write_rejectedevent; it shall not redirect to a newer frame or fault the PLC solely because the engineering request is stale. H implements this rule through specification 34 HOST-09. Continue does not copy a detached debugger frame back into a program's locals. A write or a force queued while a scan is held is applied when the hold ends, before the held statement runs (specification 44 §13.2). A debugger's write at a hold (debug.set) is not queued: it goes into the held scan at once. - A successful force remains active across scans, pause/resume, and a non-terminating detach until an authorized release or a runtime clearing boundary.
- Release removes the force but does not write a replacement value.
- Deliberate stop, fault handling, and warm or cold restart clear queued writes and active forces before safe-state handling or restarted execution.
- Authentication or authorization changes apply to later commands and do not silently clear an existing force. The force remains visible to authorized clients until release or a clearing boundary.
These are truST debugger/runtime lifecycle rules outside the IEC language execution model, not IEC deviations.
Runtime control I/O surface¶
- An I/O snapshot reports the force marks captured with that snapshot's scan.
A force accepted after scan
ncannot retroactively decorate scann; it appears only when a later snapshot records it. - Each
%I,%Q, and%Mprocess-image area is bounded to byte offsets0..=16_777_215. Control write and force requests at or above16_777_216reject before allocation or mutation. - Pause, debug-state read, warm-restart request, and queued I/O write remain independently routed operations. A composite routing behavior lock does not replace their deeper state-machine, mutation-lifetime, or restart proof.
- Attach-mode I/O and variable read/write/force/release parameter grammar, address and scalar admission, fail-closed snapshot behavior, queue ordering, target identity, and force projection follow Runtime control I/O and variable mutation contract.
Stepping Semantics¶
The following are required semantics for DAP step requests:
1) Step In (stepIn):
- Resume execution and stop at the next executed statement.
- If the next statement is a call, stepping enters the callee and stops at the first statement
inside the called function/method.
2) Step Over (next):
- Resume execution and stop at the next statement in the current frame.
- Calls are executed without entering the callee.
3) Step Out (stepOut):
- Resume execution and stop at the next statement after returning to the caller.
Stepping is statement-granular, not instruction-granular. A step boundary is a statement start recorded in the program image's statement table (specification 44 §5.9), on every runtime.
Stopped Events¶
StoppedEvent.reasonmust match the cause:breakpointfor active breakpoints,stepfor stepping commands,pausefor explicit pause requests,entryfor stop-on-entry.
Runtime Debug-Control Execution¶
Statement locations use the first non-trivia token. Breakpoint resolution
prefers the innermost containing statement and otherwise selects the next
statement at or after the requested position. Runtime resolution returns that
statement start, and a breakpoint hit pauses before the statement, emits one
breakpoint stop carrying the current breakpoint generation, and resumes only
after an explicit continue.
After continue, an unchanged breakpoint remains eligible when its statement is executed in a later scan, but one breakpoint identity emits at most one stop per scan even when several VM debug-map entries overlap its source range. A breakpoint installed after launch or while cyclic execution is running becomes eligible at the next matching statement boundary; execution completed before installation remains unstopped.
Step In stops at the next executed statement. Step Over ignores deeper frames and stops at the next statement at the starting frame depth. Step Out ignores the current and deeper frames and stops after returning to the caller. Each step resumes from a paused state and pauses again at exactly that boundary.
The runtime updates the active frame location at each statement boundary. Resuming a paused multi-task scan preserves the configured task execution order; a pause must not restart or reorder the remaining tasks.
A conditional breakpoint whose expression is false does not pause; a true condition follows ordinary breakpoint stop behavior. A hit-count breakpoint stops exactly when its accepted threshold is reached. A logpoint evaluates its registered fragments, appends one formatted output record, and never pauses execution solely because the logpoint was reached. A condition and a logpoint fragment are expressions in the compiled form of specification 11 §6.13.2; a runtime evaluates the compiled form it was sent and parses no text.
Debug-control event delivery is loss-resistant at the local process boundary.
If a registered runtime-event receiver has been dropped, the event is retained
in the in-memory runtime-event buffer. A logpoint's formatted record is kept in
the debugger's bounded log buffer until debug.stops drains it (specification
11 §6.13.2). Receiver failure must not discard the first fault or log record.
Breakpoint evaluation, source resolution, and hook contract¶
A runtime breakpoint matches a statement only when the statement has the breakpoint's file ID and
starts inside the breakpoint's half-open source span: breakpoint.start <= statement.start <
breakpoint.end. A statement that starts at the breakpoint's end does not match. An enclosing
statement starts before the statement the breakpoint names, so it does not match: a breakpoint on a
body statement stops at that statement, not at the first instruction of the compound statement
around it. Each statement's span covers its full text (Source Mapping, rule 1).
Breakpoints are inspected in registration order and the first eligible stopping
breakpoint owns the stop. The cycle-suppression identity is the breakpoint's
(file_id,start,end,generation), and it suppresses only an ordinary stop from that
exact identity. It does not suppress a breakpoint at a different location, a later
generation, or logpoint output.
The hit counter increments with saturating arithmetic whenever location identity matches and the exact stop has not been cycle-suppressed. Hit conditions are applied to the incremented count before expression evaluation. A missing evaluation context, evaluation failure, false Boolean, or non-Boolean condition does not stop. It still retains the physical hit count. A true Boolean condition proceeds to the ordinary stop.
A logpoint never returns a stop. It concatenates literal and expression
fragments in order. Boolean values use TRUE/FALSE; STRING/WSTRING and
CHAR/WCHAR use their unquoted text; arrays use [<element-count>]; structures
use <type> {...}; enums use <type>::<variant>; present and absent references
use REF and NULL_REF; instances and null use Instance and NULL.
Other values use their stable debug representation. An expression error is
rendered in place as <error: ...> without dropping surrounding literal text.
A live log receiver gets one owned record and no duplicate buffer entry; an
absent or closed receiver retains that same record in the local buffer.
Runtime source coordinates are zero-based byte coordinates. Line-start construction always contains byte offset zero and adds the byte following every LF, including a final LF; CR remains part of the preceding line. Offset projection clamps an offset beyond the source to source end. Location projection uses the location start.
Hosted runtime mapping (owner, 18 October 2026). A runtime that holds the source text resolves through the image's statement table and projects each table position to the byte offsets above through that text. Those offsets are the ones the statement index held before, so the answers are byte-identical and the text decides no answer. A runtime without the text reports line and column from the table, and a client maps them to bytes with its own copy of the sources. The statement table carries statement positions only (file, POU, start and end line and column); it stores no line starts and no line lengths (specification 44 §5.9), so a runtime cannot compute byte offsets without the text.
Breakpoint resolution first rejects an unavailable line, clamps the requested column to that line's byte end, and ignores statements from other file IDs. On the requested line it selects the statement with the greatest start column not after the request; if none exists, it selects the smallest start column after the request. If no statement starts on that line, it selects the narrowest statement containing the clamped offset, using inclusive runtime location ends for compatibility. Otherwise it selects the globally earliest later statement. An empty candidate set returns no location. Statements, their spans and the line extents come from the statement table on every runtime, so these rules are one implementation, not one per platform (owner, 18 October 2026).
At every statement start the debugger records the statement's location and
call depth before it makes a stop decision, discards the locations of frames no
longer present, and binds the current frame to the statement. A pause, step, or
breakpoint stop holds the scan, and the reads answer from a copy of the held
scan's memory and call stack until the scan is released (specification 44
§13.2). Stop publication always updates last_stop and the ordered stop buffer
that debug.stops drains. A non-target runtime thread never consumes or blocks
on another thread's pause or step.
Runtime debug state, mutation, and projection invariants¶
A newly created runtime debug control starts running with current thread 1,
no target thread, breakpoint, stop, snapshot, buffered event, queued mutation,
or active force. An explicit pause transitions a running control to
paused, records one pending pause reason, clears step state and the captured
snapshot (the halted state of the last stop: reads then answer from the last scan
that ended, completed or faulted, if there is one, specification 11 section 6.13.1), and optionally
binds the requested thread. Pausing an already paused
control is ignored and does not replace the original pending reason or target.
Stop-on-entry follows the same one-pending-stop rule but records entry and is
not thread-targeted.
Continue always returns to running mode, removes every pending stop and step, clears the target thread and captured snapshot, and wakes a waiting target. Step In, Step Over, and Step Out replace any earlier step request, clear a pending stop and captured snapshot, resume execution, and target the explicit thread or otherwise the current thread. Step In stops at the next statement after it is armed. Step Over uses that thread's last observed depth and stops when execution returns to the same or a shallower depth. Step Out uses the saturating predecessor of that depth and therefore remains defined at depth zero. A non-target thread continues without consuming the target's pending stop or step.
Replacing breakpoints for one file leaves other files unchanged, increments that file's generation with saturation, resets every replacement breakpoint's hit count/generation ownership to the new set, and preserves request order. An empty replacement removes that file's breakpoints while still advancing its generation. Clearing all breakpoints also clears the generation table and the per-cycle duplicate-stop guard.
State-query snapshots are owned copies. Reading breakpoints, frame locations, the latest stop, the runtime snapshot, or the active forces cannot allow a caller to mutate debug-control state. Draining logs, stops, runtime events, or queued mutations returns the complete current sequence in arrival order and atomically leaves that sequence empty.
Runtime-event streaming is single-subscriber. With a live sender, each event is delivered once and is not also buffered. When no sender is configured, the event is buffered. If delivery discovers a closed sender, that sender is removed and the same event is buffered before later events. Clearing the sender makes subsequent events buffer. I/O snapshots are best-effort telemetry: a configured live sender receives a coherent owned snapshot, while no sender or a closed receiver produces no synthetic snapshot or runtime fault. Registering a new sender replaces the previous sender.
The runtime keeps no watch expressions: a client evaluates its watches with
debug.evaluate (specification 11 §6.13.2).
Queued I/O writes preserve every request in arrival order. Pending global, retained, instance, and local variable writes use their complete target as identity: a later value for the same target replaces it in place, while different target kinds, instance/frame IDs, or names remain distinct. Pending lvalue writes preserve every request in arrival order. Variable and I/O forces also replace the same exact target in place; release is exact and idempotent. The runtime termination boundary clears all queued I/O, variable, and lvalue writes plus all variable and I/O forces atomically.
DAP variable handles are positive session-local identifiers. Allocation
preserves the complete handle kind, does not alias two live handles, and clear
invalidates every old handle before restarting allocation at 1; a bounded
table also releases the handles of an array's oldest pages when a later page
needs the room (specification 11 §6.13.3, "Handle bound"). Scalar and
null values have variablesReference = 0; structs, arrays, instances,
non-null references, and scope roots allocate expandable handles. Primitive
type names use canonical IEC spelling, while a struct or enum uses its
declared public type name. A caller-supplied display or declared type overrides
derived presentation without changing expandability.
Entry and array projections preserve input/field/element order and stable
evaluate names. A page of an array names each element [<index>] by its zero-based
position in the whole array, not in the page, and an entry whose value is an array or a
structure reports indexedVariables (its elements) or namedVariables (its members),
the size of the whole container (specification 11 §6.13.3, "Variable pages").
Instance-list entries use <type>#<normalized-id> as their
display name, the public type name as both value and type, and no evaluate
name. An I/O entry uses its declared symbol name when present and otherwise a
canonical uppercase direct address; its value is the runtime debug value,
error: <detail>, or unresolved. I/O scope availability is true only when a
captured snapshot contains at least one input, output, or memory entry.
DebugSnapshot expression evaluation and lvalue read/write use only the
captured storage. A supplied frame ID must exist and is active only for the
duration of that operation; an unknown frame returns InvalidFrame without
falling back to the current/global context. A snapshot write changes only the
owned snapshot until an independently authorized runtime mutation is queued.
SourceLocation::new preserves the exact file and byte offsets.
DebugBreakpoint::new creates an unconditional, zero-hit, zero-generation
breakpoint. Hit conditions use exact unsigned arithmetic: equality matches
only the target, at-least includes the target, and greater-than excludes it,
including at 0 and u64::MAX.
Stack Trace and Navigation¶
1) StackTraceRequest returns the adapter's current captured stack projection
only after confirming that the requested thread ID is present in the current
ThreadsResponse projection. An absent ID fails with an unknown-thread error
and no stack body; it never substitutes another thread or a synthetic Main
frame. The current adapter does not claim distinct per-task frame stacks for
multiple simultaneously projected task threads.
2) The top frame location is the current statement location.
3) For multi-file projects, when execution enters a function in another file, the top frame’s
source.path must reflect that file, and the editor should navigate there.
4) A runtime without source text names a frame's file by its label in source.path, and reports the
frame's line and column from the statement table (specification 44 §5.9; specification 11
§6.13.3).
5) The adapter answers the client with the file itself: a label resolves under the project root,
the folder a launch built the program from or the runtime root an attach names, as a breakpoint's
relative path resolves the other way ("Source Mapping", rule 4), so that the editor opens the project's
file. A path under that root as the build resolved it through symbolic links is named under the root
as the client named it, so that a project opened through a link, as macOS's temporary folder /var
links to /private/var, opens the file the editor has open. Any other absolute path stays. The same
holds for a logpoint's source.
Variables / Evaluate¶
VariablesRequestandScopesRequestreturn locals, globals, retain, and instance scopes.VariablesRequesttakes the DAP argumentsfilter(indexedornamed),startandcount, and aVariablereportsindexedVariables(the elements of an array) andnamedVariables(the members of a structure), so that a client that supports variable paging lists a large container in chunks, which VS Code shows as[0..99],[100..199]and so on, and asks for each chunk with those arguments. In an attach session the adapter passesfilter,startandcountunchanged to the runtime'sdebug.variables, which cuts the page (specification 11 §6.13.3, "Variable pages"), and passes the counts of the runtime's reply on unchanged; an argument that the client leaves out is left out, and astartof0is a value and is sent. A request withoutstartandcountlists an array's first page of 100 elements. A launch session lists the program that it runs in its own process, which has no handle bound, and does not page: it reports no counts.- Values are those of the runtime's published debug state (specification 11 §6.13.1): the storage as it stood at the end of the last scan that ended, completed or faulted (after a fault, the storage as the fault left it), one consistent state and never half a scan, and never a copy the runtime makes per statement; while a hold is active, the halted state.
EvaluateRequestinhoverorwatchcontext must not have side effects. Explicitly whitelisted pure standard functions, conversions, and split-date/time helpers are accepted; user-defined, unknown, and impure calls are rejected.- Evaluate, watch, conditional-breakpoint and logpoint expressions are compiled by the tool, not
by the runtime (owner, 18 October 2026). The adapter compiles the user's ST text, beside the
language server, into the portable compiled form of specification 11 §6.13.2 and sends that form
with the form's version. The runtime parses no ST text for any of the four and needs neither the
parser, the HIR nor the authoring type registry. The same form serves all four surfaces; the
runtime's answer, its stable errors and their mapping onto DAP (
<error: ...>in a logpoint fragment, a false condition that does not stop, a failed evaluate response) are specified there. The user's text stays in the editor for display and is recompiled after a reload. setVariableandsetExpressionwrite, force and release a variable, as "setVariable and setExpression" below says; the Live Values I/O custom requests (stIoWrite,stIoForce,stIoRelease) write, force and release a process-image address.
setVariable and setExpression¶
The owner, 26 October 2026: "did you run a program, simulate and force values etc? did you test all
workflows?". A debug session writes, forces and releases a variable as CODESYS and TwinCAT write,
force and release values, through the runtime's debug.set, set, var.force and var.unforce
(specification 11 §6.13.4 and "Runtime control I/O and variable mutation contract"):
- The value text.
Xwrites the valueX;force: X,force Xand!Xforce it;release,unforceandautorelease the force and write no value.Xis an IEC 61131-3 literal of the variable's declared type (specification 11, "Runtime control I/O and variable mutation contract"):3.5for aREAL,T#5sfor aTIME,'text'for aSTRING,RedorColor#Redfor an enumeration,TRUEand41as before. - The variable.
setVariablenames the variable a listing showed, by the listing'svariablesReferenceand the variable'sname: a global, a retained variable, a local of a frame on the held call stack (a variable of the instance the frame runs on, or a frame variable), a member of an instance, a member of a structure or an element of an array. A child of an evaluated value, a watch expression's structure for example, is a copy, not a variable, and is refused with a reason that says to write the variable by its path.setExpressionnames an I/O address or the name of an I/O row, which are written, forced and released at the address with the I/O requests, or else a variable path (Main.Pumps[2].Speed,GVL.Table[1, 3]), the names compared without case, whose first name is looked up among the variables of the request'sframeIdfirst while the debugger holds the scan. The Live Values commandstrust-lsp.debug.expr.write,.forceand.releasesendsetExpressionwithX,force: Xandrelease. - At a hold (a breakpoint, a step or a pause), a write goes into the held scan at once
(specification 44 §13.2): the statements after the stop see it and the Variables view shows it on
its next read. A force is written at once as well and is held from then on as a Live Values force
(
var.force) by the variable's path; a release ends it (var.unforce). A local of a frame has no path: it is written at a hold and is never forced. - While the program runs, a write is applied once at the start of the next scan (
set), and a force holds the variable at its value at every scan start and again before the outputs are sampled until it is released (var.force,var.unforce), by the variable's path. The runtime first finds the variable and admits the value (debug.set), so a refusal reaches the user before anything is queued. - Refusals. A path the program does not hold, a constant, an instance, which holds no value of its own, and a value its type refuses fail the request with the runtime's reason; nothing is written, queued or forced.
- A busy runtime. A runtime whose command queue is full refuses a request with the stable code
resource_busyand queues nothing (specification 11 §6.9). The adapter then sends that request of the write, force or release again after 75, 150, 250, 400 and 600 ms, at most five times more, and answers with the runtime's last answer. This holds forsetVariable,setExpressionand the Live Values I/O requestsstIoWrite,stIoForceandstIoRelease; the client shows the answer and sends nothing again itself (26 October 2026: the retry the extension made moved into the adapter, with the extension's waits). - The answer carries the value the runtime reports, written or admitted, and its type, with
variablesReference0. After each write, force and release the adapter sendsinvalidatedwith the areavariables, so that the client reads the variables again; a write of an I/O row also sendsstIoState. - The process image as it changes. While a session is connected to its runtime, the simulator's
or an attached one, the adapter reads the runtime's I/O on a connection of its own every 150 ms
and sends
stIoStatewhenever the image differs from the one it sent last, so that Live Values and Control Studio follow the scans without asking for each one (specification 25 §4.4); a paused program, whose image does not change, sends nothing.
Compiler scratch slots of a frame (the @scratch/<slot> rows, which hold a FOR loop's end and step
temporaries) are never listed to a user: no scope or variable list shows them. Where an internal
reader shows them, such as a published snapshot read by a test or a tool, they hold the values the
running code stored in those slots.
Variable Visibility¶
Debugger scopes and variable visibility follow IEC variable sections and access rules.
The Retain scope projects the installed retained global and program-member
checkpoint destinations from their ordinary global/instance storage. Program
members use the checkpoint's canonical @program/<root>/<member> identity;
editing or forcing such a row targets that current declared destination through
the same checked owner/queue boundary as its normal scope. The scope shall not
create a separate executable retain bank or guess live instance IDs. Detached
inspection fixtures may contain a legacy bank solely for presentation coverage.
Rules:
- Local scopes include variables declared in the active POU’s VAR,
VAR_TEMP, VAR_INPUT, VAR_OUTPUT, and VAR_IN_OUT sections.
(IEC 61131-3 Ed.3, Tables 13–14; §6.5.1–6.5.2)
- Global scopes include VAR_GLOBAL, VAR_EXTERNAL, VAR_ACCESS, and
VAR_CONFIG symbols resolved to their declared names, not raw access paths.
(IEC 61131-3 Ed.3, §6.5.2.2, Tables 13–16)
- Instance scopes expose the variables declared in the instance’s FB/CLASS
VAR sections, respecting access specifiers. (IEC 61131-3 Ed.3, §6.5.2.3)
- Access specifiers are not enforced for debugger inspection yet;
PRIVATE/PROTECTED/INTERNAL members may be visible.
Debugger inspection is a truST tooling surface and does not change
source-level IEC access enforcement.
- Directly represented variables (AT %I/%Q/%M) are presented by symbolic
name; the address may be shown as metadata, not as a separate scope.
(IEC 61131-3 Ed.3, §6.5.5, Table 16)
Safe Points¶
Debugger safe points align with Structured Text statement boundaries. The runtime may pause only before executing a statement and never within expression evaluation. (IEC 61131-3 Ed.3, §7.3.3.1, Table 72)
A runtime holds only at a statement's start and nowhere inside it: a compiled program begins each statement with the statement hook, before any of its work (specification 44 §6.6, §13.2), so a statement's start is the boundary the runtime may hold at and the unit a step returns to. A release image has no statement hook and never holds (specification 44 §13.2).
Reload / Hot Reload¶
stReloadreplaces runtime sources and revalidates breakpoints.- Successful source/schema replacement invalidates compiled watch expressions, their scratch registries and the adapter's expression-text cache together. Retired frame/snapshot locations, step/stop state and queued mutations are also cleared; old variable handles are never reused to identify new storage. UI watch definitions remain, and the failed-candidate path preserves the old installation.
User watch definitions remain in the editor; the next ordinary evaluation
reparses and registers them against the current installed metadata. A failed
reload preserves the previous watch set and cache. Ordinary same-schema restart
does not require a watch-schema replacement.
- If the session was paused before reload, it remains paused after reload.
- A reload of a launch is an online change of the running program
(specification 44 §12): the simulator keeps its cycle, the io.toml drivers
and the plants of simulation.toml that the launch attached, so a changed
io.toml or plant configuration takes effect at the next launch.
- Source compilation and runtime reload are fail-closed. A build error, a
refused image or a refused online change (specification 44 §12) returns a
failed response and preserves the previously running program and its live
state. The adapter must not emit a successful reload or replace
its current metadata on that path.
- The runtime transaction and state-preservation boundary is normative in
docs/specs/11-runtime-engine.md#671-online-change-transaction.
Reload Trigger Policy (Required)¶
To avoid breaking step-in/step-out and multi-file navigation, reloads must follow these rules:
1) No reload on editor focus:
- Opening a file or changing the active editor must not trigger stReload.
- This includes stepping into a function in another file.
2) Allowed reload triggers: - Explicit user action (e.g., command: “Reload Runtime”). - Optional: save events for ST files (if enabled), but never on focus change.
3) Program path correctness:
- The program argument of stReload must always reference the configuration entry
file (the same one used in LaunchRequest), not the currently focused file.
4) Reload must not override step stops:
- If a stepIn/stepOver/stepOut stop just occurred, reload must not emit a pause stop
that replaces the step stop or changes the top frame.
- If reload happens while paused, it must preserve the existing top frame until the user resumes.
DAP Session Lifecycle and Request Ordering¶
The adapter SHALL model one debugger connection as the following monotonic session lifecycle:
New -> Initialized -> StartPending -> Configured -> Active -> Terminated.
StartPending records exactly one launch or attach request, including its
request sequence and arguments. Active is either a managed local launch or a
remote attachment; the two modes are mutually exclusive for the lifetime of
the connection.
The lifecycle obeys these rules:
initializeis the first accepted session request. A successful call records the client's line and column coordinate conventions, returns the declared capabilities, and emits exactly oneinitializedevent. A secondinitializefails, emits no additionalinitializedevent, and does not reset coordinates, pending start state, breakpoints, watches, runner state, or attachment state.launch,attach, andconfigurationDonebefore successful initialization fail with a response correlated to the rejected request. A malformedinitialize,launch, orattachargument object fails closed; it is not replaced with default arguments and does not advance the lifecycle.- Before configuration completes, the first valid
launchorattachbecomes the sole pending start. It emits no start response until the start is either executed or rejected. A second start request, whether it is the same or the other start kind, fails immediately and cannot replace or mutate the first pending request. - Breakpoint setup while an attach is pending is kept for the attachment
(24 October 2026: VS Code sends its breakpoints after
initializedand beforeconfigurationDone, so an attach that dropped them never stopped at a breakpoint the editor already had). The adapter records each source's latest breakpoints and answers them unverified, each with an id of its own and the messagepending until the attach completes. Once the attach connects, it sends each recorded source's breakpoints to the runtime (breakpoints.set, the lines in ascending order) and reports each breakpoint's result with abreakpointevent (reasonchanged) carrying that id: verified at the resolved line and column, or unverified withno statement at or after this linefor a line the runtime does not resolve, which lies after the file's last statement. A failed attach drops the record. Breakpoints set after the attach connects go to the runtime at once. - Configuration requests, including breakpoint setup, may occur while a start
is pending and cannot execute or discard that start.
configurationDonefirst returns its own successful response, then executes the exact pending request and returns that request's response. Each response preserves the corresponding request sequence. RepeatingconfigurationDoneafter the configured transition is idempotent and cannot repeat a launch, attach, runner start, pause-on-entry action, or control-server bind. - For compatibility with clients that omit
configurationDone, a pending start MAY be executed after the documented timeout or immediately before the first non-configuration request. This fallback executes the same recorded request exactly once, reports that compatibility path through an internal diagnostic event, and preserves the pending start response's request sequence. It never applies to a configuration request. - A successful managed launch schedules
stopOnEntryand runner startup only after its launch response has been emitted. Each scheduled action is consumed at most once. A failed reload or control-server bind emits a failed launch response and schedules neither action. - A successful attach never reloads the local program, starts the managed runner, or binds a local control server. Attach failure leaves the adapter disconnected and does not create a partially active session.
- Once a launch is active, attach is rejected; once an attachment is active, launch and a second attach are rejected. Start-mode rejection has no effect on the active session.
- An unsupported request in a live session returns one failed response, correlated to the request, and changes no lifecycle state. Non-request DAP envelopes are ignored and cannot produce a response or event.
disconnectandterminateeach return their response before oneterminatedevent, preserve the optionalrestartvalue in that event, and end the adapter connection. Repeated teardown processing cannot emit another termination event. Disconnect from an attached runtime detaches only; it does not stop, restart, reload, resume, or otherwise mutate that runtime unlessterminateDebuggee=trueis both explicitly requested and supported.
Lifecycle tests SHALL observe request correlation, response/event ordering, state preservation, and side-effect counts. Inspecting only a final enum variant does not prove that a displaced pending request or duplicated action was avoided.
DAP Transport and Session Projection¶
DAP JSON wire schema¶
The adapter's serialized protocol types form a normative wire contract. They SHALL obey all of the following rules:
- Every request, response, and event envelope carries
seqplus a lowercasetypediscriminator. Requests requirecommand; responses requirerequest_seq,success, andcommand; events requireevent.arguments, responsemessage/body, and eventbodyare omitted when absent rather than emitted as JSONnull. - The canonical response correlation field is
request_seq. For compatibility with previously emitted internal payloads, deserialization MAY also acceptrequestSeq; serialization MUST NOT emit that alias. - DAP object fields use their protocol lower-camel-case spellings, including
threadId,frameId,variablesReference,sourceReference,allThreadsStopped,allThreadsContinued,sourceModified,endLine, andendColumn. The DAP field namedtypeis emitted astype, never as the Rust raw identifier spelling. Unsupported discriminator or enum spellings are rejected. - Optional fields are omitted when absent. A numeric or Boolean zero value is still an explicit value and MUST NOT be discarded. Required identifiers, names, values, source objects, and collection fields fail deserialization when missing or of the wrong JSON shape.
- Initialize capabilities are flattened into the initialize response body. Unknown launch and attach arguments are preserved as a flat string-keyed JSON object so that the adapter can inspect product-specific arguments without changing the DAP envelope.
- The custom I/O snapshot body requires
inputs,outputs, andmemory.scanis optional. Each entry requiresaddressand textualvalue;forceddefaults tofalsewhen absent and is always serialized so clients can distinguish an unforced sample from missing state. - The custom variable snapshot body requires
globalsandretain.localsandinstancesdefault to empty and are omitted when empty;frameIdandpausedare optional. Instance entries require a stable numericid, a displayname, and theirvars. - Custom I/O write/force requests require
addressand textualvalue; release requiresaddress. A custom variable mutation requires lowercasescope,name, and optional lowercaseaction;value,instanceId, andframeIdremain optional so release and scoped instance operations have one unambiguous shape. - Source, thread, stack, scope, variable, evaluate, and mutation payloads keep the DAP-required fields explicit. Optional source locations, child counts, evaluate names, type names, and frame selectors are omitted when absent.
- A source-breakpoint request requires
line; its condition, hit condition, log message, and column are optional. A breakpoint result always carriesverified. The verified and unverified constructors preserve the requested source position while differing only in verification state and optional failure message. stReloaduses the optional lower-camel-case fieldsprogram,runtimeIncludeGlobs,runtimeExcludeGlobs,runtimeIgnorePragmas, andruntimeRoot.setBreakpointsrequiressource;breakpointLocationsrequiressourceandline; their optional ranges and legacy line lists are omitted when absent.
These wire rules are independent of handler semantics. A handler test that constructs a Rust value without verifying its JSON projection does not prove this contract.
The debug adapter SHALL preserve the following protocol and session contracts:
- Standard input/output transport uses DAP
Content-Lengthframing, preserves a request's sequence in the responserequest_seqfield, and round-trips one complete request without consuming bytes from a following frame. - A successful
initializerequest emits the initialized event exactly once. Launching before initialization SHALL NOT manufacture that event. - Continue followed immediately by pause produces an ordered pause stop. Pausing without an active storage frame targets the global runtime, and the DAP thread list projects configured runtime tasks without changing their identity.
- The simulator is a computer's runtime (specification 44 NX-16) and runs with
the project's resource cycle, as the deployed runtime does: the
[resource] cycle_interval_msof the project'sruntime.toml, or 10 ms without one. Its tasks keep their own intervals and run at the first cycle at or after they are due (specification 10 §4.3). The control status reports that cycle (cycle_interval_ms), also after a reload. It executes at least one cycle while active, does not hot-loop between deadlines, and remains responsive to stop requests. - A
restartrequest to the simulator's control server restarts its program as trust-runtime restarts one (specification 11 §6.7): cold with the starting values, warm with the retained values kept. - Stack, scope, and I/O-state projection SHALL use a captured runtime snapshot and SHALL NOT wait on the live runtime mutex after that snapshot is available. When no storage frame or source location exists, a synthetic Main frame remains inspectable for a known thread and its scopes fail gracefully. A thread ID absent from the current DAP thread projection is rejected with no stack body.
- Evaluate accepts side-effect-free value and type expressions, including
names resolved through active
USINGnamespaces. Impure or unknown calls are rejected. Primitive values use stable user-facing debugger formatting. - Source discovery expands ordinary and nested brace globs. It is the
simulator's source selection of specification 25 §11.6
(
trust_runtime::runtime_sources), which the language server shares. Hit-condition parsing accepts the documented comparison operators, while logpoint templates accept valid interpolation and reject malformed messages. - Reload with no breakpoint requests clears prior breakpoints. Reload with retained requests re-resolves them against the new source before reporting them as valid.
These rules describe the DAP/session product boundary. Internal helper success, source-text inspection, or a fabricated fallback for an unknown thread does not establish this contract.
Managed Runtime Debug Mutation and Active Reload¶
Managed-local and attached debugging SHALL preserve these mutation and reload boundaries:
- Configured
REALandTIMEvalues are admitted through typed I/O writes. Attach-modesetExpressionand custom ST I/O force/release requests forward the same declared address, value, and force action to the remote runtime. - Direct instance-field force works in both live and paused sessions. Local output and memory force/release use the declared process-image target; one-shot writes to output I/O are rejected instead of being reported as successful.
- Launch fails closed if its control endpoint cannot be bound. Project ADS bindings are validated before reload changes the active program.
- While the runner is active, reload SHALL NOT emit a pre-scan I/O snapshot. The first reported snapshot after reload is produced after a coherent scan of the replacement program.
Mutation forwarding, bind/configuration rejection, and post-reload snapshot ordering are independently observable. Success in one partition SHALL NOT be used as evidence for another.
Required Improvements (Architecture + Behavior)¶
The following items are required to align the implementation with this specification and to avoid the observed instability in multi-file debugging sessions. These requirements are derived from the DAP references above and the current runtime/adapter architecture.
1) Stop Reason Integrity¶
- The adapter must not emit
StoppedEvent{reason="breakpoint"}if there are no active breakpoints at stop time. - Pending stop reasons must be cleared on
continue,step*, or breakpoint removal.
2) Breakpoint Generation / Staleness Guard¶
- Breakpoint sets must be versioned. Each
SetBreakpointsRequestincrements a generation number and runtime stops must only be honored if they match the current generation. - Clearing breakpoints (
SetBreakpointsRequestwith an empty list) must immediately invalidate any pending breakpoint stops.
3) Reload Semantics¶
stReloadmust preserve paused/running state explicitly:- If the session was running, it stays running after reload.
- If the session was paused, it stays paused after reload with
StoppedEvent{reason="pause"}. - The state machine must include an explicit Reloaded transition to avoid ambiguity.
4) Per‑Frame Source Mapping¶
StackTraceRequestmust report each frame with its own source location (file/line/column), not the top-of-stack location for all frames.- When a function in another file is entered, the top frame must point to that file; caller frames must continue to show their original source locations.
5) Pause/Continue Idempotency¶
PauseRequestwhile already paused must be a no-op (no additional pause events).ContinueRequestmust clear any adapter-side pause expectation and runtime pending pause.
6) Stop-on-Entry Reason¶
stopOnEntrymust emitStoppedEvent{reason="entry"}(notpause) per DAP semantics.- This reason must be distinct in logs and internal state to avoid confusion with manual pause.
7) DAP Event Ordering¶
- For requests that cause a stop (pause/step), the adapter must send the response first and
emit
StoppedEventafter the response, matching DAP requirements.
8) Multi‑Task Thread Model¶
- If multiple IEC tasks are configured, each must map to a distinct DAP thread ID.
- The thread IDs are 1, 2, ... in the order the resource declares its tasks, and the programs that run outside every task are one more thread after the last task (specification 11 §6.13.3).
step*andpausemust apply to the thread specified by the request.
9) Cyclic Task Breakpoint Safety¶
- In cyclic tasks, a breakpoint hit must not starve
continue: - If the breakpoint is cleared, the runtime must resume without re-triggering the old stop.
- If the breakpoint remains, the adapter should support hit conditions to avoid infinite stops.