Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v4.1.10
v4.2.0
2 changes: 2 additions & 0 deletions core/src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ add_executable(plc_main
${CMAKE_SOURCE_DIR}/core/src/plc_app/journal_buffer.c
${CMAKE_SOURCE_DIR}/core/src/plc_app/debug_write_journal.cpp
${CMAKE_SOURCE_DIR}/core/src/plc_app/plc_io_cycle.cpp
${CMAKE_SOURCE_DIR}/core/src/plc_app/plc_retain.cpp
${CMAKE_SOURCE_DIR}/core/src/plc_app/plc_retain_file_store.cpp
${CMAKE_SOURCE_DIR}/core/src/plc_app/plc_state_manager.cpp
${CMAKE_SOURCE_DIR}/core/src/plc_app/plc_switch.c
${CMAKE_SOURCE_DIR}/core/src/plc_app/plcapp_manager.c
Expand Down
32 changes: 31 additions & 1 deletion core/src/drivers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,14 @@ void cleanup(void); // Called when plugin is being unloaded
// Per-cycle hooks (called during each PLC scan cycle, synchronized with PLC execution)
void cycle_start(void); // Called at start of each scan cycle, before PLC logic
void cycle_end(void); // Called at end of each scan cycle, after PLC logic

// Optional: retain-variable storage. A plugin exporting both retain_save and
// retain_load becomes the device's retain store (first one found wins).
// retain_clear is optional on top of that; without it the plugin simply
// cannot be cold-reset.
int retain_save(const uint8_t *blob, uint16_t len); // Persist the blob. Must not block.
int retain_load(uint8_t *out, uint16_t cap, uint16_t *out_len); // Restore the blob.
int retain_clear(void); // Discard the stored blob.
```

**Important: Native Plugin Args Lifetime**
Expand Down Expand Up @@ -581,6 +589,18 @@ void plugin_driver_cycle_start(plugin_driver_t *driver);
// Plugins opt-in by implementing cycle_end(); opt-out by not implementing it
void plugin_driver_cycle_end(plugin_driver_t *driver);

// Find the plugin acting as this device's retain store (first plugin that
// exports both retain_save and retain_load), or NULL if none does
plugin_instance_t *plugin_driver_find_retain_store(plugin_driver_t *driver);

// Save/load/clear the retain blob through the chosen store plugin
// Return 0 on success; retain_save/retain_load return -1 if `store` isn't a
// valid store, retain_clear is a no-op (returns 0) if the plugin has no
// retain_clear export
int plugin_driver_retain_save(plugin_instance_t *store, const uint8_t *blob, uint16_t len);
int plugin_driver_retain_load(plugin_instance_t *store, uint8_t *out, uint16_t cap, uint16_t *out_len);
int plugin_driver_retain_clear(plugin_instance_t *store);

// Destroy the plugin driver and free resources (calls 'cleanup' on plugins)
void plugin_driver_destroy(plugin_driver_t *driver);
```
Expand Down Expand Up @@ -683,7 +703,17 @@ void plugin_driver_destroy(plugin_driver_t *driver);
}
```

4. **Memory Management (Python):**
4. **Native Plugin Retain Store:**

A native plugin that owns retention hardware (FRAM, battery-backed SRAM, a data partition, etc.) can become the device's retain store by exporting `retain_save()` and `retain_load()`. The runtime marshals retained variables into an opaque blob and hands it to the store; the plugin only needs to persist and return bytes, with no knowledge of what they mean.

* `retain_save(blob, len)`: Called once per scan cycle, unconditionally, from the scan's quiescent window. Must return promptly and must not block — this runs inside the scan cycle.
* `retain_load(out, cap, out_len)`: Called once at program start to restore the last saved blob.
* `retain_clear(void)`: Optional. Discards the stored blob so the next start uses the program's declared initial values. Called on program upload and by the runtime's `RETAIN:CLEAR` command. A plugin without it simply cannot be cold-reset.

**Both `retain_save` and `retain_load` are required** to be selected as the store; a plugin exporting only one is ignored. If more than one plugin exports both, the first one found wins and the rest are logged and ignored.

5. **Memory Management (Python):**
* Python's garbage collector handles memory. However, explicitly close files, sockets, or release other external resources in `cleanup()`.

## Dependencies
Expand Down
81 changes: 81 additions & 0 deletions core/src/drivers/plugin_driver.c
Original file line number Diff line number Diff line change
Expand Up @@ -1446,6 +1446,14 @@ int native_plugin_get_symbols(plugin_instance_t *plugin)
// get_stats is fully optional — plugins that don't publish statistics
// simply don't export it. No warning.

// Retain store (NODE-94), fully optional. A plugin exporting both becomes
// a candidate for this device's retain store; see
// plugin_driver_find_retain_store. No warning when absent — most plugins
// have nothing to do with retention.
native_bundle->retain_save = (plugin_retain_save_func_t)dlsym(handle, "retain_save");
native_bundle->retain_load = (plugin_retain_load_func_t)dlsym(handle, "retain_load");
native_bundle->retain_clear = (plugin_retain_clear_func_t)dlsym(handle, "retain_clear");

// Store the native bundle and handle in the plugin instance
plugin->native_plugin = native_bundle;

Expand Down Expand Up @@ -1473,6 +1481,79 @@ void python_plugin_cycle(plugin_instance_t *plugin)
// Call cycle_start for all active native plugins that have registered the hook
// This should be called at the beginning of each PLC scan cycle, before PLC logic execution
// Plugins opt-in by implementing cycle_start(); opt-out by not implementing it (NULL pointer)
// ---------------------------------------------------------------------------
// Retain store
// ---------------------------------------------------------------------------

static bool plugin_provides_retain_store(const plugin_instance_t *p)
Comment thread
dcoutinho1328 marked this conversation as resolved.
{
if (!p) return false;

// A DISABLED plugin is not a store, even though its symbols resolved.
//
// Loading resolves symbols for every plugin in plugins.conf; only starting
// is gated on `enabled`. Without this check a disabled plugin is still
// picked as the store, so retain reports itself active, hands it the blob
// every scan, and gets nothing back on the next boot — the values are
// simply gone, with a log line at start saying retain is configured and
// working. Found on hardware: an upload rewrote plugins.conf, disabled the
// storage plugin, and retain went on claiming to work.
if (!p->config.enabled) return false;

// BOTH halves required. A store that can save and not load is worse than
// none: it would accept values every scan and silently never give them
// back, which looks like working retention right up until the reboot that
// matters.
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

{
if (!driver) return NULL;

plugin_instance_t *chosen = NULL;
for (int i = 0; i < driver->plugin_count; i++)
{
plugin_instance_t *p = &driver->plugins[i];
if (p->degraded || !plugin_provides_retain_store(p)) continue;

if (!chosen)
{
chosen = p;
continue;
}
// Two stores would both appear to work and disagree on the next boot,
// which is a worse failure than refusing the second. First wins, and
// the rest are named so the misconfiguration is visible.
log_warn("Retain: plugin '%s' also provides retain storage; ignoring it — "
"'%s' was found first",
p->config.name, chosen->config.name);
}
return chosen;
}

int plugin_driver_retain_save(plugin_instance_t *store, const uint8_t *blob, uint16_t len)
{
if (!plugin_provides_retain_store(store)) return -1;
return store->native_plugin->retain_save(blob, len);
}

int plugin_driver_retain_load(plugin_instance_t *store, uint8_t *out, uint16_t cap, uint16_t *out_len)
{
if (out_len) *out_len = 0;
if (!plugin_provides_retain_store(store)) return -1;
return store->native_plugin->retain_load(out, cap, out_len);
}

int plugin_driver_retain_clear(plugin_instance_t *store)
{
// Optional third hook: a plugin without it cannot be cold-reset, and
// reporting that as failure would make the editor's post-upload clear look
// broken on every such device.
if (!store || !store->native_plugin || !store->native_plugin->retain_clear) return 0;
return store->native_plugin->retain_clear();
}

void plugin_driver_cycle_start(plugin_driver_t *driver)
{
if (!driver || driver->plugin_count == 0)
Expand Down
47 changes: 47 additions & 0 deletions core/src/drivers/plugin_driver.h
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,35 @@ typedef int (*plugin_execute_command_func_t)(const char *command_json, char *res
// Return 0 on success; any other value means "skip me this cycle."
typedef int (*plugin_get_stats_func_t)(char *out, size_t out_size);

/* ---- Optional: retain-variable storage (NODE-94) -------------------------
*
* A plugin that owns retention hardware exports these two and becomes the
* device's retain store. Same names, same status meaning and the same contract
* text as baremetal's `openplc_retain.h`, so a vendor writes one shape twice
* rather than learning two interfaces for one job.
*
* The runtime MARSHALS and the plugin STORES: what arrives is an opaque blob,
* already validated on the way back in (magic, format, layout hash, crc32), so
* a backend needs no understanding of retained variables at all.
*
* `retain_save` is called ONCE PER SCAN CYCLE, unconditionally, from the
* dispatcher's quiescent window. The runtime does not diff and does not
* rate-limit — holding the bytes and flushing on a schedule the medium can
* sustain is the plugin's job, and the reason the call exists at that cadence
* is so a plugin that CAN write every cycle (FRAM, battery-backed SRAM) is free
* to. It MUST return promptly and MUST NOT block: this runs inside the scan,
* so time spent here is time the PLC is not scanning.
*
* Both must be exported for the plugin to be used as the store; a plugin
* exporting only one is ignored, since a store that can save and not load is
* worse than none. Return 0 on success, non-zero otherwise.
*/
typedef int (*plugin_retain_save_func_t)(const uint8_t *blob, uint16_t len);
typedef int (*plugin_retain_load_func_t)(uint8_t *out, uint16_t cap, uint16_t *out_len);
/* Optional third: discard the stored blob. A plugin without it simply cannot
* be cold-reset, and the editor's post-upload clear is a no-op for it. */
typedef int (*plugin_retain_clear_func_t)(void);

typedef struct
{
void *handle; // Handle to the loaded shared library
Expand All @@ -44,6 +73,10 @@ typedef struct
plugin_cleanup_func_t cleanup;
plugin_execute_command_func_t execute_command;
plugin_get_stats_func_t get_stats;
/* Optional retain-store hooks; NULL unless the plugin exports them. */
plugin_retain_save_func_t retain_save;
plugin_retain_load_func_t retain_load;
plugin_retain_clear_func_t retain_clear;
} plugin_funct_bundle_t;

// Plugin instance structure
Expand Down Expand Up @@ -119,6 +152,20 @@ void plugin_driver_release_gil(void);
void plugin_driver_cycle_start(plugin_driver_t *driver);
void plugin_driver_cycle_end(plugin_driver_t *driver);

/* ---- Retain store ------------------------------------------------------- */

/* The plugin acting as this device's retain store, or NULL.
*
* The FIRST plugin that exports both retain_save and retain_load wins, and any
* others are logged and ignored. Two plugins writing the same retained values
* to different places would both appear to work and disagree on the next boot,
* which is a far worse failure than refusing the second. */
plugin_instance_t *plugin_driver_find_retain_store(plugin_driver_t *driver);

int plugin_driver_retain_save(plugin_instance_t *store, const uint8_t *blob, uint16_t len);
int plugin_driver_retain_load(plugin_instance_t *store, uint8_t *out, uint16_t cap, uint16_t *out_len);
int plugin_driver_retain_clear(plugin_instance_t *store);

// Route a command to a specific plugin by name (for async commands like scan)
int plugin_driver_execute_command(plugin_driver_t *driver, const char *plugin_name,
const char *command_json, char *response, size_t response_size);
Expand Down
14 changes: 14 additions & 0 deletions core/src/plc_app/image_tables.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ uint16_t (*ext_strucpp_debug_size) (uint8_t, uint16_t) = nullp
uint8_t (*ext_strucpp_debug_set) (uint8_t, uint16_t, bool,
const uint8_t *, uint16_t) = nullptr;
uint16_t (*ext_strucpp_debug_read) (uint8_t, uint16_t, uint8_t *) = nullptr;
size_t (*ext_strucpp_retain_blob_size) (void) = nullptr;
uint32_t (*ext_strucpp_retain_layout_hash)(void) = nullptr;
size_t (*ext_strucpp_retain_pack) (uint8_t *, size_t) = nullptr;
uint8_t (*ext_strucpp_retain_unpack) (const uint8_t *, size_t,
uint8_t (*)(uint8_t, uint16_t,
const uint8_t *, uint16_t)) = nullptr;
uint8_t (*ext_strucpp_debug_write) (uint8_t, uint16_t,
const uint8_t *, uint16_t) = nullptr;
int (*ext_strucpp_debug_locate) (uint8_t, uint16_t, uint8_t *,
Expand Down Expand Up @@ -251,6 +257,14 @@ extern "C" int symbols_init(PluginManager *pm)
*(void **)&ext_strucpp_debug_set = resolve(pm, "strucpp_debug_set", true);
*(void **)&ext_strucpp_debug_read = resolve(pm, "strucpp_debug_read", true);
*(void **)&ext_strucpp_debug_write = resolve(pm, "strucpp_debug_write", true);

/* Optional: a program built by an older STruC++ has no retain exports, and
* the retain path then simply never runs. `required = false` so that is a
* quiet degradation rather than a failed load. */
*(void **)&ext_strucpp_retain_blob_size = resolve(pm, "strucpp_retain_blob_size", false);
*(void **)&ext_strucpp_retain_layout_hash = resolve(pm, "strucpp_retain_layout_hash", false);
*(void **)&ext_strucpp_retain_pack = resolve(pm, "strucpp_retain_pack", false);
*(void **)&ext_strucpp_retain_unpack = resolve(pm, "strucpp_retain_unpack", false);
/* Optional: present only on .so's built with strucpp_capabilities bit 2.
* When NULL the debug-write drain routes every leaf as a global write. */
*(void **)&ext_strucpp_debug_locate = resolve(pm, "strucpp_debug_locate", false);
Expand Down
22 changes: 22 additions & 0 deletions core/src/plc_app/image_tables.h
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,28 @@ extern "C"
const uint8_t *bytes,
uint16_t len);

/* ---- Retain marshalling (NODE-94) --------------------------------------
*
* The WALK lives inside the .so, not here: that is where the debug tables
* and handle_read/handle_write are, 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 because the runtime owns the write path.
* A retained variable may also be LOCATED (`VAR RETAIN x AT %MW10`), and
* poking such a leaf's IECVar is undone by the next copy-in from the
* process image — so the callback we hand over routes through
* runtime_external_write, which knows to send a located leaf through the
* image journal.
*
* Optional: a program built by an older STruC++ resolves these to NULL and
* the retain path simply never runs. */
extern size_t (*ext_strucpp_retain_blob_size) (void);
extern uint32_t (*ext_strucpp_retain_layout_hash)(void);
extern size_t (*ext_strucpp_retain_pack) (uint8_t *out, size_t cap);
extern uint8_t (*ext_strucpp_retain_unpack) (const uint8_t *blob, size_t len,
uint8_t (*write_leaf)(uint8_t, uint16_t,
const uint8_t *, uint16_t));

/* Located-variable classifier. Reports whether a debug (arr, elem) leaf is
* a LOCATED variable and, if so, its image location (area / size /
* byte_index / bit_index). Returns 1 + fills the out-params if located, 0
Expand Down
Loading