fix(retain): review fixes — buffer boundary, clear/save race, diagnostics [NODE-94] - #178
Open
thiagoralves wants to merge 1 commit into
Open
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Addresses the review on #174. Targets the feature branch so #174 picks it up.
The buffer cap admitted a size the API cannot express
RETAIN_BUFFER_MAXwas64 * 1024= 65536 while the plugin retain API takesuint16_t len/cap, whose maximum is 65535. Soneeded == 65536passed the check,g_activewent 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 atUINT16_MAX.Clear could be undone by an in-flight flush
plc_retain_save()runs on the PLC cycle thread every scan;RETAIN:CLEARruns on the control-socket thread, and the upload path fires it unconditionally without waiting for the PLC to stop. Two distinct problems:The built-in file store.
g_lockcovers 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 couldremove()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 — the exact thing clear-on-upload exists to prevent. A separateg_store_locknow serialises the file operations, still without holding anything across the scan path.The plugin store. No lock at all, so a vendor's
retain_saveandretain_clearcould 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.Half a store is a mistake, not a configuration
A plugin exporting
retain_savebut notretain_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, and one I deliberately did not write
14 new pytest cases for
clear_retained()andretain_status(): that a clear never propagates a failure into the upload, that a failure is reported asERRORrather thanOK(the caller logs "cleared"), and that an unreadable or truncated status reply comes backunknownrather 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 inCMakeLists, no CI wiring, andunity.his 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, and worth doing.Verification
Rebuilt on an SLM-RP4 via
install.sh, clean. Retain still restores across a program reload:And the race the lock was added for: an upload against a running PLC now leaves the store absent — the clear is honoured, no in-flight flush resurrects it, and the next start logs
nothing stored yet.🤖 Generated with Claude Code
https://claude.ai/code/session_012FNp61926UEggtmsQPj3A3