feat(retain): retain-variable persistence for runtime v4 [NODE-94] - #174
feat(retain): retain-variable persistence for runtime v4 [NODE-94]#174thiagoralves wants to merge 7 commits into
Conversation
… reset
Phase 4 of NODE-94. The daemon half: retained values now round-trip through a
plugin-supplied store, with the same contract baremetal already has.
The WALK lives inside the .so. `runtime_v4_entry.cpp` exports
`strucpp_retain_pack` / `_unpack` / `_blob_size` / `_layout_hash`, because the
debug tables and `handle_read` are there and the runtime is built once but
loads many .so files — it cannot reach `strucpp::retain` by mangled name, and
re-implementing the blob format on this side would put two copies of one wire
format in two repos.
`unpack` takes a write CALLBACK rather than using `handle_write` itself, and
that is the important detail. A retained variable may also be located
(`VAR RETAIN x AT %MW10`); poking such a leaf's IECVar is undone by the next
copy-in from the process image, so the value would appear to restore and then
silently revert on the first scan. The runtime passes a thunk that routes
through `runtime_external_write`, which classifies the leaf and sends a located
one through the image journal — the path OPC-UA writes already take. Reads need
no such care, so pack uses `handle_read` directly.
`plc_retain.{h,cpp}` owns the buffer and the call sites. `plc_retain_save()`
runs once per scan from the dispatcher's quiescent window, where
`g_tasks_running == 0` and no worker is inside a body — the same guarantee
`copy_config_globals_out` relies on, and the only place the leaves can be read
without racing the task threads. `plc_retain_init()` / `_load()` run in the
start path after located variables are bound, because a retained variable may
also be located and its image slot has to exist before anything writes through
it.
There is deliberately NO default backend. Retention hardware is a property of
the device, not of the runtime, and a file-backed default here would look like
support on every device and be wrong on most of them. With no plugin the calls
are no-ops and retain degrades to NON_RETAIN — what the runtime did before.
The plugin surface mirrors baremetal's `openplc_retain.h` name for name. Both
`retain_save` and `retain_load` are required to be a store: one that can save
and not load is worse than none, because it accepts values every scan and
silently never returns them, which looks like working retention right up to the
reboot that matters. The first plugin exporting both wins and any others are
named and ignored — two stores would both appear to work and disagree on the
next boot.
`RETAIN:CLEAR` on the unix socket, called by the webserver on upload: CODESYS
clears retained memory on download, and while the layout hash already refuses a
genuinely different program, two programs that happen to share a retain layout
would otherwise inherit each other's values silently.
VERIFIED ON THE SLM-RP4, with a throwaway file-backed plugin flushing every 5
seconds so the asynchronous half of the contract was actually exercised:
- init reported `18 bytes ... (layout 0590d5d2)`, matching the layout hash the
editor put in debug-map.json for the same program;
- the blob on disk decoded to exactly the designed header — magic 0x4F52,
format 1, layout 0590d5d2, payload 4, valid crc;
- the file's mtime advanced every 5 s while the runtime called `retain_save`
at 50 Hz — roughly 250 API calls per write, which is the decoupling the
per-cycle cadence exists to allow;
- across a STOP/START, which unloads and reloads the .so and re-runs every
declared initialiser, `boots` came back at its stored value and kept
counting while `counter`, which is not retained, correctly reset.
Found while deploying, NOT caused by this work: openplc-runtime's development
branch requires `vpp_signature.json` (webserver/vpp_package_signature.py, which
does not exist on main) and openplc-editor never emits it, so any VPP-target
upload from a current editor to a current-development runtime fails. Tested
against the non-VPP runtime-v4 target instead. Worth its own fix.
Refs NODE-94
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_012FNp61926UEggtmsQPj3A3
…untime-retain feat(retain): runtime v4 retain — .so marshalling, plugin store, cold reset
Loading a plugin resolves its symbols; only starting it is gated on `enabled`. `plugin_driver_find_retain_store` checked neither, so a disabled plugin whose .so had loaded was still chosen as the store. The result was retain that reported itself working and was not. Init logged "Retain: N bytes across the program's retained variables (layout …)", the scan cycle handed the blob over every cycle, and the plugin — never started, its flush thread never running — kept none of it. The values were simply gone on the next boot, with nothing in the log suggesting anything was wrong. Found on hardware, and by the most ordinary route: uploading a project rewrote plugins.conf and disabled the storage plugin, and retain went on claiming to work. Anyone whose retain storage got switched off would have seen the same thing. With the check, the same configuration now logs "Retain: N bytes of retained variables, but no plugin provides storage — they will start at their initial values", which is the honest report and the one that says what to do about it. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_012FNp61926UEggtmsQPj3A3
…etain-store-enabled fix(retain): a disabled plugin is not a retain store [NODE-94 phase 5]
Retention hardware is a property of the device, and a VPP that provides its own backend still wins outright. But "no backend" and "FRAM" are not the only two cases: a Linux box running runtime v4 has a filesystem, and a file on it is a perfectly good place to keep retained values. Making every vendor write that same file backend just to get retain at all is a poor trade — and during a review cycle it means retain cannot be tried on a device at all without first writing a driver for it. So the runtime ships one. It is DISABLED by default: writing to the data partition on a cadence the operator did not choose is not something to switch on for everyone. An SD-card-backed box has an endurance budget its owner may be counting on. Until someone turns it on, retain is a no-op exactly as before and every retained variable starts at its declared initial value. PRECEDENCE ---------- A VPP plugin ALWAYS wins over the built-in store. The vendor knows what the box actually has; a file on the data partition is the fallback, not the preference. Silently writing to disk on a device whose driver just implemented proper retention would be both slower and wrong. The two are reported separately, because they are different questions. `RETAIN:STATUS` on the control socket answers what is holding the bytes RIGHT NOW — none / plugin / file — so the editor can show "the settings are saved, and a driver is doing the work" rather than "enabled" over a file that will never grow. CONFIGURATION ------------- `./retain.conf` beside `plugins.conf`, read once per program load: enabled, path, flush_seconds. Flat key=value rather than JSON because the core parses it in C++ during startup, and a dependency-free parser for three keys beats pulling a JSON library into the PLC application. `flush_seconds` bounds how much retained state a power cut costs against how hard the storage is worked. That is a real trade and it belongs to whoever installs the machine, which is why it is configuration. Committed by write-and-rename with an fsync of the file AND of the directory. The file fsync commits the contents; the rename that publishes them is a directory operation and can still be lost to a power cut after the data is safely down — which looks exactly like retain silently skipping an interval. REST ---- `GET /api/retain-config` (any authenticated user) and `PUT /api/retain-config` (admin) — the same split the user endpoints use: seeing how a device is configured is not the same privilege as changing it. Settings the runtime could not honour are refused at the API, not discovered later as a store that never writes: a relative path, a directory that does not exist, a target that is itself a directory, an impossible flush period. The path check is explicitly NOT a privilege boundary and says so — the caller is an admin who can already upload a program the runtime compiles and executes. It is there for ordinary mistakes. Verified on an SLM-RP4. With the built-in store enabled and no plugin: "Retain: built-in file store enabled … flushing every 5s", then "restored 54 bytes", and a retained TON comes back already elapsed while its un-retained twin restarts its wait. With the synergy VPP plugin loaded, the same device reports "stored by synergy" and the API reports `enabled=true → backend=plugin (synergy)`. 60 pytest green (21 new). Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_012FNp61926UEggtmsQPj3A3
NODE-94 adds an optional retain_save/retain_load/retain_clear plugin hook and the matching plugin_driver_retain_* API, but core/src/drivers/README.md was not updated to cover them. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01ACXXWVDG9HEZwAmtRRzBsp
dcoutinho1328
left a comment
There was a problem hiding this comment.
Reviewed the retain-variable persistence feature (runtime side of NODE-94). The design is solid and well-reasoned (marshal-in-runtime/store-in-plugin split, first-store-wins, DBGW_OP_WRITE instead of a force for restores), but I found two correctness/safety issues worth fixing before merge, plus a testing gap and a minor diagnostics suggestion. Details inline.
| * something to discover on a running machine. A program needing more is | ||
| * refused at init with a message naming both numbers. | ||
| */ | ||
| constexpr size_t RETAIN_BUFFER_MAX = 64 * 1024; |
There was a problem hiding this comment.
❌ Correctness — integer truncation at the retain buffer boundary
RETAIN_BUFFER_MAX is 64 * 1024 = 65536, but the plugin retain API (plugin_retain_save_func_t/plugin_retain_load_func_t in plugin_driver.h) takes uint16_t len/cap (max 65535). The needed > RETAIN_BUFFER_MAX check a few lines below admits needed == 65536, so a program whose retain blob is exactly 65536 bytes gets g_buffer.assign(65536, 0) and g_active = true. Then in plc_retain_load() and plc_retain_save(), the (uint16_t) casts of g_buffer.size() / the packed length wrap 65536 to 0 — the plugin's retain_load/retain_save gets called with cap/len == 0. Retain silently fails to restore or persist for exactly the one blob size the runtime's own error message a few lines above ("handles at most %zu") claims is supported.
Fix: cap RETAIN_BUFFER_MAX at UINT16_MAX (65535) instead of 64 * 1024, or widen the plugin retain API to uint32_t/size_t.
There was a problem hiding this comment.
Fixed in #178 — the analysis is exactly right.
RETAIN_BUFFER_MAX is now UINT16_MAX. Your trace was correct end to end: needed > 65536 admitted 65536, g_active went true, and then all four (uint16_t) casts — two in plc_retain_load, two on the save path — wrapped to 0, so the plugin got cap/len 0 and retain silently neither saved nor restored. At exactly the one size the error message above it advertises as supported.
| * Answers OK even with no retain plugin: "discard what is stored" is | ||
| * satisfied by a device that stores nothing, and failing there would | ||
| * make every upload to such a device look broken. */ | ||
| plc_retain_clear(); |
There was a problem hiding this comment.
❌ Thread safety — retain_save/retain_clear can run concurrently on the same plugin instance with no synchronization
This runs on the Unix Socket Thread, invoking plc_retain_clear() while plc_retain_save() runs every scan cycle on the PLC Cycle Thread whenever the PLC is RUNNING (plc_state_manager.cpp). webserver/app.py's handle_upload_file() calls runtime_manager.clear_retained() unconditionally on every upload — it never checks whether the PLC is currently RUNNING. So uploading a new program while the current one is still running fires RETAIN:CLEAR on the socket thread at the same moment the scan thread may be inside plugin_driver_retain_save() (plugin_driver.c) for the same plugin instance. Neither plugin_driver_retain_save/_load/_clear nor anything upstream takes a lock around the call into the plugin's C function pointers, and neither the PR description nor the core/src/drivers/README.md contract documents that a plugin's retain_save/retain_load/retain_clear must tolerate concurrent invocation from two different threads.
For a plugin backed by a file or a not-fully-atomic write (the expected case — FRAM/SRAM/data-partition backends), this can corrupt or lose the stored blob.
Fix: either serialize retain calls (a mutex around the plugin's retain entry points), gate clear_retained()/upload on the PLC being STOPPED, or explicitly document in the plugin contract that these callbacks may be invoked concurrently and must self-synchronize.
There was a problem hiding this comment.
Fixed in #178, and there was a sharper version of this inside the built-in store.
You are right that nothing serialised the plugin entry points. The driver now takes one mutex across retain_save / retain_load / retain_clear, held only for the plugin call — which the contract already requires to return promptly, so an uncontended acquire per scan is the whole cost. That is the option you listed first, chosen over documenting "must self-synchronise" because it puts the burden in one place instead of on every vendor.
The built-in file store had the same race in a more concrete form: g_lock covers the staging buffer and is released before the write (deliberately, so the scan thread never waits on disk I/O), which left the file unguarded. A clear could remove() it while a commit sat between its write and its rename, and the rename then republished the blob a moment after it was meant to be gone — the next start would restore the previous program's values, which is precisely what clear-on-upload exists to prevent. A separate g_store_lock now serialises the file operations.
Exercised on an SLM-RP4: an upload against a running PLC now leaves the store absent and the next start logs nothing stored yet.
I did not gate clear_retained() on the PLC being STOPPED. Making the clear correct under concurrency seemed better than making the upload depend on PLC state, but say the word if you would rather have the gate as well.
| return p->native_plugin && p->native_plugin->retain_save && p->native_plugin->retain_load; | ||
| } | ||
|
|
||
| plugin_instance_t *plugin_driver_find_retain_store(plugin_driver_t *driver) |
There was a problem hiding this comment.
❌ Testing — no coverage for the new retain-store selection or clear-on-upload behavior
This PR adds four new public functions here (plugin_driver_find_retain_store, plugin_driver_retain_save/load/clear) plus a whole new subsystem (plc_retain.cpp) and a new RuntimeManager.clear_retained() method, but no test file touches any of it. tests/test_plugin_driver.c already exists and tests this exact file (plugin lifecycle, config, init failure paths) — the "first store wins, rest are logged and ignored" selection logic and the "disabled plugin is not a store" bug fix bundled in this same PR (both genuinely subtle, regression-prone logic) have zero coverage there. review-guidelines.md also calls out pytest coverage for the Python side (clear_retained() / the upload-triggers-clear behavior in handle_upload_file), which is likewise untested.
Suggestion: at minimum, add cases to tests/test_plugin_driver.c for (a) first-plugin-wins when two plugins export both hooks, (b) a disabled plugin is never chosen, (c) a plugin exporting only one of retain_save/retain_load is not chosen; and a pytest case for clear_retained()'s error-swallowing behavior.
There was a problem hiding this comment.
Partly addressed in #178, and I want to be straight about the part I declined.
Added: 14 pytest cases for clear_retained() and retain_status() — that a clear never propagates a failure into the upload, that a failure reports ERROR rather than OK (the caller logs "cleared", so answering OK about a device that still holds the old values is the failure that matters), and that an unreadable or truncated status reply comes back unknown rather than being guessed as "no retention configured".
Not added: the cases in tests/test_plugin_driver.c. That harness has no build target in CMakeLists, no CI wiring, and unity.h is not vendored anywhere in the repo — it cannot compile or run today. Cases added there would read as coverage and be none, which I think is worse than an honest gap.
So the C-side selection logic you name — first-store-wins, disabled-is-not-a-store, half-a-store-is-not-a-store — is currently proven only on hardware. That is a real hole and your instinct about it is right; the fix is giving that harness a build, which is its own piece of work. Happy to take it if you want it in this cycle.
| // Retain store | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| static bool plugin_provides_retain_store(const plugin_instance_t *p) |
There was a problem hiding this comment.
retain_save/retain_load is rejected silently
This correctly requires both hooks, but a plugin author who implements only one (typo, WIP, or misunderstanding) gets no diagnostic — it's silently treated as "not a store," identical to a plugin with neither. The "two full stores" case a few lines below (plugin_driver_find_retain_store) does log a warning naming the plugin. Given the whole point of this PR's disabled-plugin fix was surfacing exactly this kind of silent misconfiguration, a one-line log_warn here ("plugin '%s' exports retain_save but not retain_load, ignoring it as a retain store") would be consistent and cheap.
There was a problem hiding this comment.
Fixed in #178.
plugin_provides_retain_store now logs when exactly one of the two hooks is present, naming the plugin and which half is missing. Your framing is the reason it went in: the whole point of the disabled-plugin fix was surfacing silent misconfiguration, and half a store is a typo or an unfinished driver rather than a configuration — treating it identically to "no store at all" leaves the vendor nothing to go on.
PR #174 ReviewSummaryRuntime-side implementation of retain-variable persistence for STruC++ programs (NODE-94): the runtime marshals retained variables into an opaque blob (via Module: PLC Runtime Core / Plugin System (C/C++)Checklist Results
Code Improvement FindingsNo duplication or extraction issues found; the retain marshalling split (runtime owns the buffer/call sites, Module: REST API Server (Python)Checklist Results
Cross-Module ObservationsThe thread-safety finding spans three files: Verdict
|
…tain-store feat(retain): a built-in file store for runtime v4, off by default [NODE-94]
…tics Addresses the review on #174. THE BUFFER CAP ADMITTED A SIZE THE API CANNOT EXPRESS ---------------------------------------------------- `RETAIN_BUFFER_MAX` was 64 * 1024 = 65536 while the plugin retain API takes `uint16_t len` / `cap`, whose maximum is 65535. So `needed == 65536` passed the check, `g_active` went true, and then every `(uint16_t)` cast of the length wrapped to 0: the plugin was handed cap/len 0 and retain silently neither saved nor restored — at exactly the one size the error message above it claims is supported. Capped at `UINT16_MAX`. CLEAR COULD BE UNDONE BY AN IN-FLIGHT FLUSH ------------------------------------------- `plc_retain_save()` runs on the PLC cycle thread every scan; `RETAIN:CLEAR` runs on the control-socket thread, and the upload path fires it unconditionally without waiting for the PLC to stop. Two distinct problems came out of that: * In the built-in file store, `g_lock` covers the staging buffer and is released before the write — deliberately, so the scan thread never waits on disk I/O. That left the FILE unguarded: a clear could `remove()` it while a commit sat between its write and its rename, and the rename then republished the blob a moment after it was meant to be gone. The next start restored the PREVIOUS program's values, which is the exact thing clear-on-upload exists to prevent. A separate `g_store_lock` now serialises the file operations, still without holding anything across the scan path. * For a plugin store there was no lock at all, so a vendor's `retain_save` and `retain_clear` could be entered concurrently. Nothing in the plugin contract says they must be reentrant with each other, and for the expected backends — a file, an FRAM page, an NVS partition — that overlap is how a store ends up torn. The driver now serialises its three retain entry points. Held only across the plugin call, which the contract already requires to return promptly, so an uncontended mutex acquire per scan is the whole cost. HALF A STORE IS A MISTAKE, NOT A CONFIGURATION ---------------------------------------------- A plugin exporting `retain_save` but not `retain_load` (or the reverse) was silently treated as "not a store" — indistinguishable from a plugin that never meant to provide one. The two-stores case already logs; this is the same courtesy for the more likely error, which is a typo or an unfinished driver. TESTS ----- 14 pytest cases for `clear_retained()` and `retain_status()`: that a clear never propagates a failure into the upload, that a failure is reported as ERROR rather than OK (the caller logs "cleared"), and that an unreadable or truncated status reply comes back `unknown` rather than being guessed as "no retention configured" — the Persistent Storage screen would otherwise state something it does not know. The review also asked for cases in `tests/test_plugin_driver.c`. I did not add them, and the reason is worth recording: that harness has no build target in CMakeLists, no CI wiring, and `unity.h` is not vendored anywhere in the repo — it cannot compile or run today. Cases added there would look like coverage and be none. The C-side selection logic (first-store-wins, disabled-is-not-a-store, half-a-store-is-not-a-store) is currently proven only on hardware; giving that harness a build is its own piece of work. Verified on an SLM-RP4 after rebuilding: retain still restores across a program reload (a retained TON back at Q=True ET=10s STATE=2 while its un-retained twin restarts at ET=3s200ms), and an upload against a running PLC now leaves the store absent — the clear is honoured and no in-flight flush resurrects it. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_012FNp61926UEggtmsQPj3A3
Review response — fixes in #178@dcoutinho1328 all four findings were valid; three are fixed and one is partly fixed with the gap named. Each inline thread has its own reply. Fixed
I did not gate
Partly fixed — and the part I declined
Added: 14 pytest cases for Not added: the cases in So the C-side selection logic you named — first-store-wins, disabled-is-not-a-store, half-a-store-is-not-a-store — is currently proven only on hardware. That is a real hole and you are right to push on it; the fix is giving that harness a build, which is its own piece of work. I will take it if you want it in this cycle. VerificationRebuilt on an SLM-RP4 via And the race the lock was added for: an upload against a running PLC now leaves the store absent, with the next start logging |
The runtime's half of RETAIN support. Lands with STruC++ #222, openplc-editor #1034 and openplc-web #691.
The split
The runtime marshals; a plugin stores. Marshalling itself lives inside the loaded
.so— STruC++'siec_retain.hpp, reached through thestrucpp_retain_*exports — because that is where the debug tables live, and because one copy of a wire format is better than two.Why there is no default backend
Retention hardware is a property of the device, not of the runtime: an SLM-RP4 has a data partition, another box has FRAM or battery-backed SRAM, a third has nothing. A file-backed default here would look like support on every device and be wrong on most of them. With no plugin the calls are no-ops and retain degrades to
NON_RETAIN— what the runtime did before this existed.Plugins opt in by exporting
retain_save/retain_load/retain_clear, dlsym'd optionally in the same patternget_statsalready uses. Both save and load are required: a store that accepts values every scan and never gives them back looks like working retention right up until the reboot that matters. Two stores would both appear to work and disagree on the next boot, so first wins and the rest are named in the log.Where the calls sit
plc_retain_save()runs in the dispatcher's quiescent window —g_tasks_running == 0underimage_lock(), the same guaranteeimage_tables_copy_config_globals_out()relies on. Reading the leaves anywhere else would race the task threads.Restores go through
runtime_external_write(..., DBGW_OP_WRITE, ...), not straight to theIECVar. A retained variable may also be located (VAR RETAIN x AT %MW10), and poking such a leaf's storage directly is undone by the next copy-in from the process image — the value would appear to restore and then silently revert on the first scan.DBGW_OP_WRITEand never a force: restoring must not pin a value, and an operator's force has to stay authoritative.A bug this branch also fixes
Loading a plugin resolves its symbols; only starting it is gated on
enabled. The store selection checked neither, so a disabled plugin was still chosen — retain logged itself active, took the blob every cycle, and kept none of it. Found on hardware by the most ordinary route: an upload rewroteplugins.conf, disabled the storage plugin, and retain went on claiming to work.Verification
On an SLM-RP4, the blob on disk decoded to exactly the designed header —
magic=0x4F52 format=1 layout=0x618b1d38 payload_len=40 crc=…— with the layout hash matching the editor'sdebug-map.json. The flush mtime advanced every 5s whileretain_saveran at 50 Hz, roughly 250 API calls per write, which is the asynchronous contract working as intended.Across a STOP/START that unloads and reloads the
.so, a retainedTONcame back already elapsed while an identical un-retained one restarted its ten-second wait.🤖 Generated with Claude Code
https://claude.ai/code/session_012FNp61926UEggtmsQPj3A3