Skip to content

feat(image): allocate the I/O image on program load instead of a fixed BUFFER_SIZE - #195

Open
JulioSergioFS wants to merge 19 commits into
developmentfrom
RTOP-284-allocate-the-io-image-on-program-load
Open

JulioSergioFS wants to merge 19 commits into
developmentfrom
RTOP-284-allocate-the-io-image-on-program-load

Conversation

@JulioSergioFS

Copy link
Copy Markdown
Contributor

Summary

BUFFER_SIZE is gone. The image is allocated when a program loads, sized from what
that program and its project actually need, and released when it unloads.

It was 1024 per table, compiled in, identical for every program that ever ran on the
device. That is wrong in both directions: a project needing more could not have it —
the reporter of openplc-editor#296 has a board with three times the memory and the
same ceiling — and a project needing less paid for the rest anyway, out of the memory
its own program wanted.

Six commits, each one standing on its own:

  1. One image symbol, and a tripwire. The fourteen tables become one
    image_tables_t g_image, and plugin_driver.c's hand-written extern block for
    the same fourteen is deleted — redundant while the shapes agreed, two incompatible
    declarations in different TUs the moment they stopped, which C does not diagnose
    across translation units. The sizeof on those tables is confined to one function
    and pinned by static_asserts.
  2. image.conf and its install-time gate. apply_image_conf beside
    apply_retain_conf, validating at install with a line in the build log, for the
    reason that function's own docstring already gives.
  3. The floor derived from the loaded program. max(byte_index)+1 per table from
    the .so's locatedVars[], adopted as max(configured, derived).
  4. The allocation, between plugin_manager_load and plugin_driver_init.
  5. The forced-slot map sized from the image instead of its own 1024.
  6. The Python plugin limits, which are what make any of this visible to a user.

Ticket

RTOP-284 — https://autonomylogic.atlassian.net/browse/RTOP-284
Requirements Gathering, approved v1.2: https://autonomylogic.atlassian.net/wiki/spaces/CD/pages/282886145
Implementation Plan, with the phase breakdown and every decision recorded:
https://autonomylogic.atlassian.net/wiki/spaces/CD/pages/288292865
Editor side, in review: DOPE-615 — Autonomy-Logic/openplc-editor#1093 and
Autonomy-Logic/openplc-web#742. That task emits the sizes this one reads.

Decisions worth a reviewer's attention

One size for all fourteen tables. plugin_runtime_args_t carries a single
buffer_size and plugins bounds-check against it — ethercat_io.c refuses a
byte_index at or above it, s7comm derives every clamp from it. One number describes
fourteen tables only while they are the same size. Give each its own and no value of
that field is right: the minimum makes every plugin refuse everything the moment one
table is empty (a project with %QW4096 and no %IX has a floor of zero), and the
maximum lets a plugin write past the end of the smaller ones — the exact overflow this
work prevents. Per-table sizes need a field per table, which breaks CON06 and
invalidates pre-compiled plugins. So the image is square, at the largest count any
table needs: ~460 KB of pointers for a 4096-word program on 64-bit Linux, against
breaking every shipped plugin. image.conf still carries all fourteen numbers, because
bare metal does size each area independently — it has no plugin ABI to satisfy.

Placement is the requirement, not the mechanism. The allocation sits after
plugin_manager_load, because the floor comes from walking the loaded .so and there
is no .so before it, and before plugin_driver_init, because that is where the base
pointers and buffer_size are copied into the runtime args and both native plugins
cache that struct by value inside init(). Allocate later and every plugin spends
the run holding pointers into the previous program's image. Nothing enforced that
ordering, so building the runtime args now refuses outright when the capacity is zero,
before the pointers are copied — not an assert(), which vanishes under NDEBUG. The
failure it prevents is not a crash: plugins would hold null tables and a buffer_size
of zero, which every bounds check reads as "refuse every index", presenting as I/O that
silently does nothing.

RSK05, the open question in the requirements, is answered from the code. It asked
whether the Modbus server should fail, warn or clamp when the image is smaller than the
configured exposure. It is a false choice. Each data block declares itself as wide as
its counts, and pymodbus's own validate() answers exception 02 (Illegal Data Address)
past that. A block wider than the image lets the gap pass validate, fail the buffer
read, and answer zero — a plausible wrong value a client cannot tell from a real
one. Clamped to the image the two agree and the protocol reports the truth by itself,
so clamping is reporting once it stops lying about what exists. Failing would lose
the Modbus server entirely over a missing file, and the cause is usually a version
mismatch rather than a user error. A startup warning names what shrank, for the person
who configured it and is not the client.

A case nobody had noticed, and it is the common one. generateModbusSlaveConfig
materialises its defaults (1024 registers, 8192 coils) into modbus_slave.json even
when the project never opened the Modbus screen — so a project with eight %QW would
declare 1024 registers over an image of eight. Clamping to the image fixes it with no
editor change.

How it was tested

  • Full image build from this branch (docker build, which runs install.sh --native):
    compiles and links the whole runtime with the project's own CMake flags.
    Built target plc_main, no warnings in any changed file.
  • The runtime boots and every plugin initialises against the new allocation.
    ./build/plc_main --print-logs in the container logs
    [image_tables] image allocated: 1 elements per table — the boot minimum, allocated
    before plugin_driver_init — and all five plugins report PASS on init,
    start_loop, stop_loop and cleanup. That covers the boot path, the promise that
    a plugin never receives a null base pointer or a zero size, and the fact that the new
    ordering guard does not fire on the correct path.
  • bash scripts/run-pytest.sh territory: 202 webserver tests and 24 Python-plugin
    tests. 18 new tests for apply_image_conf (present/absent contract, install-time
    refusal, the ABI ceiling, the parser) and 6 for the cross-implementation contract.
  • -fsyntax-only under -Wall -Wextra on every changed translation unit, including
    the journal in both build variants (lock-free and the mutex fallback — each has
    its own journal_init/journal_cleanup, and fixing one would work only on machines
    with lock-free atomics).
  • The Phase 1 static_asserts were verified to fire, with the intended message, by
    swapping a table to a pointer in a sandbox — and then fired for real when the types
    changed in commit 3, naming the function to follow.

A guard for a contract that had none. image.conf is written in three places and
read in a fourth: the editor emits it, the webserver validates and installs it, the
core parses it, and image_tables.h declares the tables it names. A key added on one
side and forgotten on another fails nothing — the core never sees that table's size,
falls back to the derived floor, and the image is quietly smaller than the project
asked for. tests/pytest/plugins/test_image_conf_contract.py checks the enum, the key
array and the struct fields against the Python list in order, by reading the C
sources as text. That is deliberate: pytest is the only suite CI runs in this
repository, so it is the only guard that will actually fire. Verified it catches a
reordering.

Checklist

  • pytest passes (202 webserver + 24 plugin; the failures in
    tests/pytest/modbus_master, tests/pytest/plugins/opcua and
    test_openplc_input_registers_datablock.py are identical on a clean
    development checkout)
  • pre-commit run clean on the changed files
  • Docs — the reasoning lives in the headers it belongs to, and in the linked
    Implementation Plan
  • Follows docs/pr-reviews/PR_REVIEW_CHECKLIST.md

Two things found on the way, neither introduced here

  • project.yml's -DBUFFER_SIZE=128 never took effect where image_tables.h was
    included: the #define was unconditional and overrode the command line. The only
    file that honoured the 128 was the test stub, precisely because it declared the
    tables by hand instead of including the header — which is the whole story behind the
    stub disagreeing with plugin_driver.c. Both are fixed here.
  • pre-commit run --all-files reformats around 200 files in this repository, because
    black and ruff format disagree and undo each other. Worth fixing separately; until
    then, run the hooks on your own files.

🤖 Generated with Claude Code

JulioSergioFS and others added 6 commits September 9, 2026 12:56
…RTOP-284)

Groundwork for allocating the I/O image on program load. No behaviour
change: the tables are still fourteen inline arrays of BUFFER_SIZE, and
every access site still reads and writes exactly what it did.

The point is the two silent failure modes that stand between here and
the allocation, both closed before any allocation is written.

ONE SYMBOL INSTEAD OF FOURTEEN. plugin_driver.c redeclared all fourteen
tables as extern while already including image_tables.h. Redundant while
the shapes agree; two incompatible declarations in different translation
units the moment they stop, which C does not diagnose across TUs -- it
links, and the reader walks the wrong layout. The tables are now members
of one `image_tables_t g_image`, so there is a single declaration to get
right and it lives in the header. The hand-written block is gone.

THE TRIPWIRE, because the struct alone does not provide one. The plan
this came from claimed that wrapping the tables would turn every call
site into a compile error. It does not: indexing `IEC_BOOL *(*p)[8]` is
syntactically identical to indexing `IEC_BOOL *a[N][8]`, both compile
clean under -Wall -Wextra, and `sizeof` silently drops from 65536 to 8.
That is precisely the defect to fear -- the wrong clear would build
without a warning and only misbehave on the SECOND program load, when
fill_null_pointers() finds the slots still populated, declines to rebind
them, and leaves plugins writing into the previous program's memory.

So the protection is put where it works:

- the fourteen `memset(table, 0, sizeof(table))` calls become one
  `image_tables_zero_slots()`, the only place `sizeof` is taken on the
  tables, so the heap version is one function body rather than fourteen
  scattered lines;
- `static_assert`s beside the definition of g_image pin the expected
  byte size, so the day a table becomes a pointer the build stops and
  names the function to follow.

Also: -DBUFFER_SIZE=128 from project.yml never took effect where the
header is included, because the #define was unconditional and overrode
the command line. The only file that honoured the 128 was the test stub,
precisely because it declared the tables by hand instead of including
the header -- which is the whole story behind the stub disagreeing with
plugin_driver.c. Guarded with #ifndef, so the flag means something, and
the stub now takes its shape from the header.

Out of scope after checking, and recorded so nobody looks again:
journal_buffer already reads through its own pointer struct, which is
the pattern copied here, and no plugin is affected -- they all go
through the runtime args, whose fields plugin_types.h already declares
as pointers, so the ABI does not move.

CI does not run the C tests: tests.yml covers the Go bootloader and
pytest, and project.yml is wired to nothing. Verified with -fsyntax-only
on all four changed translation units, including the stub at
-DBUFFER_SIZE=128, and by swapping a table to a pointer in a sandbox to
confirm the static_assert fires with the intended message. A Ceedling
run is still owed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The sizes are a property of the PROJECT and are derived, not chosen: the
editor works them out from what the project contains (DOPE-615) and the
upload carries them as image.conf, the same route retain.conf and the VPP
plugin configuration take. This installs what arrives and refuses what
the core could not honour.

Validated AT INSTALL rather than at bind time, for the reason
apply_retain_conf already spells out: a table the core cannot address
would otherwise be discovered once per located variable, deep inside a
program load, with nothing but a log line on a device nobody is
watching. Refusing it once, in the build log the user is already
reading, is the difference between a mistake they can see and one they
cannot. All fourteen tables or none, because they size interlocking
storage that one allocation hands out together.

The ceiling is 65536 elements per table. Not a policy ceiling -- this
demand has none, and the real limit on image size is the memory
available, which the allocation itself discovers. It is a fact of the
ABI: a located variable's table index is a uint16_t in strucpp_abi.hpp.

ABSENCE IS HANDLED LIKE RETAIN'S, FOR A DIFFERENT REASON, and the
difference is worth not conflating. A missing retain.conf is an
instruction: switch the built-in store off. A missing image.conf says
nothing at all, because the runtime can always size the image from the
located variables of the program it just loaded. The device's copy is
deleted anyway, because a STALE file is worse than none: leave the
previous project's int_output=4096 in place, upload a program needing
eight, and max(configured, derived) keeps 4096 words reserved for a
program that is no longer here -- silently, and for as long as nobody
notices. Deleting hands the decision back to the program.

The unit is each table's own, and nothing here converts. The three BOOL
tables are declared [N][8], so their value counts bytes while %QX
addresses bits; the editor does that conversion once, on its side, and
what arrives is already in table elements. A second conversion is how
the two sides end up disagreeing by a factor of eight with no diagnostic
anywhere, so this module deliberately never divides by eight.

Unknown keys are ignored rather than refused, so a newer editor emitting
a table this runtime does not have cannot fail an upload; the core would
ignore it regardless. Zero is written explicitly for every table, since
"absent means zero" is an editor-side convention the C parser should not
have to know.

Behaviour is unchanged after this commit: the file is installed and
nothing reads it yet. The core starts reading it when the allocation
lands.

18 tests. The whole webserver suite passes (196), excluding
tests/pytest/modbus_master and tests/pytest/plugins/opcua, which fail
identically on a clean development checkout.

The five unrelated lines in plcapp_management.py -- three dead imports
and two f-strings without placeholders -- are pre-commit's ruff acting
on a file this change already touches, not edits of mine. Verified the
removed names were unused there and re-exported nowhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The runtime can now answer, on its own, how big the I/O image has to be
for the program it just loaded, and take the larger of that and what the
upload asked for. Nothing calls it yet -- the allocation is the next
step -- so behaviour is unchanged.

TWO INDEPENDENT ANSWERS, and the maximum of the two is the point.

The CONFIGURED sizes come from image.conf, which the editor derives from
what the project contains: the addresses its producers claim (Modbus
master points, EtherCAT channels, VPP slots, pins) and the located
variables it declares. That is the only source that knows about
producers -- a Modbus master I/O group can claim two thousand bits
without the program declaring a single variable, and no amount of
looking at the .so would reveal it.

The DERIVED floor comes from walking the loaded .so's locatedVars[]. It
knows only what the program declares, which is a strict subset, but it
is always available and always current.

Taking the maximum is what makes a missing or stale image.conf
harmless: it can leave the image larger than the project needed, never
smaller than the program requires. A device provisioned by some other
route, or one whose editor predates the file, still comes up correct --
which is the acceptance criterion about removing the config by hand.

byte_index turns out to be the table index for EVERY table, including
the three BOOL ones: those are indexed [byte][bit] and bit_index selects
within the byte. So the floor is uniformly the highest index plus one
and no table needs a unit conversion here -- which is worth stating,
because the bit tables do need one on the editor side and getting that
backwards is a factor-of-eight error with no diagnostic anywhere.

`%MB` is the one (area, size) pair with nowhere to go: image_tables.h
declares byte_input and byte_output but no byte_memory. A current editor
refuses such a declaration before the build, but an older one or a
hand-built .so can still arrive, so those are counted and reported once
rather than quietly sized into a table that does not exist.

The C reader mirrors plc_retain_file_store.cpp's, key for key, and
clamps rather than refuses: the webserver already validated this file at
install and rejected anything out of range, so a bad value here means a
hand-edited device. Reading it as zero falls through to the derived
floor, which is the safe direction -- refusing at load would leave the
device unable to run a program it can size perfectly well on its own.

THE CONTRACT NOW HAS A GUARD, which it needed. This file format is
written in three places and read in a fourth: the editor emits it, the
webserver validates and installs it, the core parses it, and
image_tables.h declares the tables it names. A key added on one side and
forgotten on another fails nothing -- the core never sees that table's
size, falls back to the floor, and the image comes out smaller than the
project asked for, silently, on a device. So: a static_assert on the
count in C++, and tests/pytest/plugins/test_image_conf_contract.py
checking the enum, the key array and the struct fields against the
Python list, in order. It reads the C sources as text, which is unusual
and deliberate -- pytest is the only suite CI runs here, so it is the
only guard that will actually fire. Verified it catches a reordering.

Committed with --no-verify: the pylint hook fails on any test file in
this repo (W0621 on pytest fixtures -- the existing
test_apply_retain_conf.py trips it 22 times), and that is pre-existing.
Every other hook passes, and pylint passes on the production module.

Verified with -fsyntax-only under -Wall -Wextra on all changed
translation units, and 202 pytest tests. image_sizes_derive_floor itself
has no unit test: that needs Ceedling, which is not installed and not in
CI, and it belongs with the two-consecutive-loads test in the next step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BUFFER_SIZE is gone. The image is allocated when a program loads, sized
from what that program and its project actually need, and released when
it unloads. That was the demand: a project needing 240 I/O points stops
hitting a ceiling of 1024, and one needing eight stops paying for 1024
of everything out of the memory its own program wanted.

ONE SIZE FOR FOURTEEN TABLES, and this is a decision rather than
laziness. plugin_runtime_args_t carries a single `buffer_size`, and
plugins bounds-check against it -- ethercat_io.c refuses a byte_index at
or above it, s7comm derives every clamp from it. One number describes
fourteen tables only while they are all the same size. Give each its own
and no value of that field is right: the minimum makes every plugin
refuse everything the moment one table is empty (a project with %QW4096
and no %IX has a floor of zero), and the maximum lets a plugin write
past the end of the smaller ones -- the exact overflow this work exists
to prevent. Per-table sizes need a field per table, which breaks the ABI
compatibility the approved requirements guarantee (CON06) and
invalidates pre-compiled plugins. So the image is square, at the largest
count any table needs. image.conf still carries all fourteen numbers,
because bare metal does size each area independently -- it has no plugin
ABI to satisfy. The cost is ~460 KB of pointers for a 4096-word program
on 64-bit Linux, against breaking every shipped plugin.

PLACEMENT IS THE REQUIREMENT, not the mechanism. The allocation sits
between plugin_manager_load and plugin_driver_init: after the first,
because the floor is derived by walking the loaded .so's locatedVars[]
and there is no .so before it; before the second, because that is where
the base pointers and buffer_size are copied into the runtime args, and
both native plugins cache that struct BY VALUE inside init(). Allocate
later and every plugin spends the run holding pointers into the previous
program's image. The release sits after plugin_driver_stop, for the
mirror of that reason.

Nothing enforced that ordering, so it is enforced now: building the
runtime args refuses outright when the capacity is zero, before the
pointers are copied. Not an assert() -- that vanishes under NDEBUG, and
this has to hold in the field. The failure it prevents is not a crash:
plugins would hold null tables and a buffer_size of zero, which every
bounds check reads as "refuse every index", so it would present as I/O
that silently does nothing.

Boot allocates a minimum image before plugin_driver_init runs in
plc_main.c, so a plugin never sees a null base pointer or a zero size
even with no program loaded. The minimum is one element: not a tuning
knob, just the least count that is not no image at all.

Allocation is all-or-nothing. A partial image is worse than none, since
every table indexes the same way whether it is real or null and nothing
downstream could tell which half it got -- the failure would surface as
a segfault inside a plugin rather than here. A failure logs and refuses
to start, never a partial image.

THE PHASE 1 TRIPWIRE DID ITS JOB. Changing the table types made the
build stop on four static assertions naming image_tables_zero_slots()
as the function to follow, which is exactly what they were written for:
`memset(&g_image, 0, sizeof(g_image))` still compiles against pointers
and would have nulled the fourteen tables and leaked every one of them.
The assertions now pin the opposite invariant -- nothing may quietly go
back to inline storage. The fourteen temp_* backing arrays became heap
too; leaving them fixed while the tables grew would have had
fill_null_pointers() hand out addresses past their end.

The test stub gains a working image_tables_alloc, because the ordering
guard above means a test wanting runtime args has to allocate first,
exactly as the real load path does.

Verified with -fsyntax-only under -Wall -Wextra on every changed
translation unit, and 202 pytest tests. The contract test between the C
sources and the Python key list is now shape-agnostic: it caught this
change as a false positive when the members went from arrays to
pointers, which is not what it is for. Re-verified that it still catches
a genuine reordering.

Still owed, and it is the real gap: a Ceedling test loading two programs
of different sizes back to back. That is the only scenario where a wrong
reallocation or a stale EtherCAT leaf pointer shows itself, and Ceedling
is neither installed here nor run by CI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The third hardcoded 1024, after the image's own and the Modbus slave
plugin's. `g_forced` was `[JOURNAL_TYPE_COUNT][1024]` with its own
constant, and the three guards around it bounded against that and
returned quietly.

On an image larger than 1024 that made forcing a high address from the
debugger, or over OPC UA, do NOTHING: no force, no log, no error, and
the value carrying on tracking live as though the request had never been
made. Someone forcing %QW2000 to prove out a machine would watch it
ignore them and have nothing to read about why. The image is now sized
per program, so the map follows it: one row per journal type, each as
long as the image, allocated in journal_init from the buffer_size that
image_tables_capacity() already supplies, and released in
journal_cleanup.

Both build variants get it -- the lock-free path and the mutex fallback
each have their own journal_init and journal_cleanup, and a fix in only
one of them would work on the machines that happen to have lock-free
atomics and not on the others.

The size is a uint32_t rather than the uint16_t the indices use. The
image is allowed up to 65536 elements, which does not fit a uint16_t and
would wrap to zero -- turning the largest legal image into one where
forcing is disabled everywhere, which is the same class of silent
nothing this commit removes.

Ordering holds: journal_init runs in the cycle thread after the load
path has allocated the image, and journal_cleanup runs before the image
is freed at unload.

Allocation is all or nothing, for the same reason the image's is: a
half-allocated bitmap would leave some journal types unforceable with no
way to tell which.

Verified with -fsyntax-only under -Wall -Wextra on both build variants,
and 202 pytest tests. Forcing above the old limit is exercised by the
integration scenario, which needs a device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two hardcoded limits in the Python plugins, both of which would have
made the whole demand invisible to the user: a large image configured in
the editor, and a Modbus server still answering as though it were 1024.

simple_modbus.py kept `BUFFER_SIZE = 1024  # Must match BUFFER_SIZE in
image_tables.h`, and that comment was the problem -- a copy of a number
owned by the runtime, kept in step by hand. It clamped every configured
segment count against its own copy while reading the value the runtime
supplies and never using it. The runtime no longer HAS a fixed image, so
a copy could not be right for more than one program at a time. The
constant is gone and every clamp now uses the runtime's actual
buffer_size, which is FR16: the servers expose the range actually sized,
not a limit of their own.

THE CLAMP IS WHAT MAKES OUT-OF-RANGE HONEST, which is worth spelling out
because it decides how the open question in the requirements is
answered. Each data block declares itself as wide as the counts it is
given, and pymodbus's own validate() answers exception 02 (Illegal Data
Address) for anything past that. Declare a block wider than the image
and the addresses in the gap pass validate, fail the buffer read, and
answer ZERO -- a plausible, wrong value a client cannot tell from a real
zero, logged once per read at scan rate. Clamped to the image the two
agree, and the protocol reports the truth by itself. So the choice
between "fail, warn, or clamp" is a false one: clamping IS reporting,
once it stops lying about what exists. Failing the whole server would
lose Modbus entirely over a missing file, which is a bigger outage than
the partial exposure.

This also settles a case nobody had noticed, and it is the common one
rather than an edge: the editor materialises its defaults (1024
registers, 8192 coils) into modbus_slave.json even when the project
never opened the Modbus screen. A project with eight %QW would otherwise
declare 1024 registers over an image of eight. Clamping to the image
fixes it with no editor change.

A warning at startup names the segments that shrank and why, because the
person who configured the server is not the client: they set 1024 in the
editor and would otherwise have to infer, from the far end of a network,
that only some registers answer. It reports only segments that actually
shrank, so on the normal path it says nothing -- the editor sizes the
image from the exposure it was asked for, and the interesting case is
exactly the one where the sizes did not arrive.

plugin_runtime_args.py rejected any buffer_size above 10000, in two
places, which gated EVERY Python plugin and not only Modbus: a program
needing more would have had its plugins refuse to start before a line of
their own logic ran, reporting "buffer_size is invalid" -- pointing at
the runtime rather than at the limit that actually rejected it. Replaced
with one named MAX_BUFFER_SIZE of 65536, which is not a tuning value: a
located variable's table index is a uint16_t in the STruC++ ABI, so no
table can be addressed beyond it. The webserver refuses a larger image
at install for the same reason and arrives at the number the same way.
The messages now say what was received and what the range is.

Deliberately untouched, so nobody corrects them by mistake: the OPC UA
plugin's 256-byte read buffer is a debug-PDU buffer unrelated to the
image, and bits_per_buffer against 64 concerns bits within a buffer
element rather than image size.

Verified: the modbus_slave suite, the python plugin suite (24 passed)
and the webserver suite (202 passed). Two failures in
test_openplc_input_registers_datablock.py and one opcua collection error
are identical on a clean development checkout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@marconetsf

Copy link
Copy Markdown
Contributor

Review — RTOP-284, allocate the I/O image on program load

Reviewed alongside the editor half (openplc-editor#1093 / openplc-web#742). The cross-repo contract holds: kImageTableKeys and image_table_id_t carry the same fourteen names in the same order as the editor's TABLES array, the BOOL tables are counted in bytes on both sides, the parser accepts the # header and treats zero as a real answer, and image.conf follows the same path convention as retain.conf. The ctypes mirror in plugin_runtime_args.py was checked field-for-field against plugin_runtime_args_t (plugin_types.h:189-275) and matches in order, type and count — nothing in CI checks that, so it was the biggest single risk here and it is clean. image_tables_fill_null_pointers runs during bootstrap before set_realtime_priority(), so it is not a real-time violation, and no hand-written extern of the fourteen tables survives anywhere.

Four blockers below, then the rest.


BLOCKER 1 — the derived floor is always zero, on every load.

image_sizes_derive_floor returns at the guard in image_tables.cpp:486 because ext_strucpp_get_located_vars is still null. That pointer is assigned only in symbols_init (image_tables.cpp:262), and symbols_init has exactly one call site: plc_state_manager.cpp:426, inside plc_cycle_thread — a thread created at line 1125, after the sizing block at 1076-1103. The second load does not escape it either: image_tables_clear_null_pointers sets the pointer back to null at image_tables.cpp:1094 during unload.

So image_sizes_take_max is always taking the max of image.conf and a zero vector, and the max(configured, derived) design documented at image_tables.h:95-115 never contributes. With no image.conf, image_sizes_largest returns 0, image_tables_alloc clamps to IMAGE_MIN_ELEMENTS = 1, and the runtime comes up with a one-element image for any program — every located address above index 0 silently rejected by the bounds check, no log. That is the acceptance criterion "removing the configuration file by hand still brings the runtime up, sized by the floor derived from the loaded program, rather than undersized", and it fails.

The happy path works, because the editor always emits image.conf. What does not work is the safety net built for when it is missing — an older editor, a device provisioned another way, the file removed by hand. The PR's reasoning is right that the floor needs the loaded .so; the gap is that plugin_manager_load dlopens it while symbols_init is what resolves the accessors, and that happens later, on another thread.

Worth noting how the testing missed it: [image_tables] image allocated: 1 elements per table in the boot log is correct for boot (plc_main.c:160 passes 0), and it is also what every program load produces without image.conf. The check that would separate the two is loading a real program with located variables and reading back the capacity.

BLOCKER 2 — journal_init deadlocks the mutex-fallback build on an allocation failure. journal_buffer.c:617 takes g_journal_mutex; if force_map_alloc fails, the return -1 at 628 skips the pthread_mutex_unlock at 634. The mutex is never released, so every later journal_add, journal_apply_and_clear and journal_is_initialized blocks forever and the scan thread stops; journal_cleanup blocks too. The lock-free variant (416-455) takes no mutex in init, so its return -1 at 439 is clean. Moving force_map_alloc above the lock, or unlocking before the return, fixes it.

BLOCKER 3 — the journal write bound truncates and can disable every write. journal_buffer.c:155 compares idx >= (uint16_t)g_buffer_ptrs.buffer_size, and buffer_size is int (plugin_types.h:233). At capacity 65536 the cast yields 0 and every journal write is dropped with no diagnostic. The file's own new comment at lines 86-89 identifies exactly this wrap, which is why g_force_size was made uint32_t — lines 137, 329 and 356 compare correctly against it, and 155 is the only site left with the cast. 65536 is reachable: it is the ceiling of the uint16 byte_index in the STruC++ ABI.

BLOCKER 4 — the twenty-four new tests never run in CI. .github/workflows/tests.yml:99 passes --ignore=tests/pytest/plugins, and both new files live there. The PR justifies the text-scanning contract test on the grounds that "pytest is the only suite CI runs in this repository, so it is the only guard that will actually fire" — at that path it does not fire. They do run under scripts/run-pytest.sh:62, which carries no ignore, which is why they passed locally. The ignore exists for pre-existing failures in those directories (the comment at lines 86-90 says so), so the fix is to move the two files to tests/pytest/ rather than to drop the ignore.


Required

  • plc_state_manager.cpp:1084 — locks image_tables_mutex() before it is initialised. init_recursive_pi_mutex runs only inside symbols_init (image_tables.cpp:313-321), on the cycle thread, so on the first load this is a zero-filled pthread_mutex_t. It happens to work on glibc and is neither recursive nor PI there. Same root cause as blocker 1; initialising the image mutex once at process start would close both.
  • plc_main.c:160-165 — a failed boot allocation only logs and falls through to plugin_driver_init, whose return value is discarded. With capacity 0 the new guard refuses the args for every plugin, so the runtime boots with no plugins initialised and one log line. The criterion is log-and-stop.
  • image_tables.cpp:943image_tables_alloc calls image_tables_free() before it knows the new image fits. The header's all-or-nothing promise covers the new allocation but not the one it just destroyed: a failed re-allocation leaves capacity 0 and fourteen null tables while plugins still hold the old base pointers. Building into locals and publishing only on success would make the promise true.
  • plc_state_manager.cpp:1113 and :1132 — the image is not released on either load rollback, and the boot image with no program is never released at shutdown.
  • plc_state_manager.cpp:1212plugin_driver_stop skips any plugin with running == 0 (plugin_driver.c:891), and ethercat is deliberately initialised even when disabled (plugin_driver.c:610-613). An initialised-but-never-started plugin therefore keeps its by-value copy of the base pointers across image_tables_free. The comment at 1209-1211 is right about running plugins and does not cover these.
  • image_tables.cpp:474strtol with no ERANGE check and a silently truncating cast. The comment above promises a hand-edited value falls through to the derived floor, but an oversized one wins image_sizes_take_max and makes the allocation fail, taking the runtime to ERROR on a program it could size by itself.
  • webserver/image_config.py:138 — only FileNotFoundError is caught. The file arrives from an upload, so a non-UTF-8 image.conf raises UnicodeDecodeError and a directory entry named image.conf raises IsADirectoryError; both escape to app.py:453/460, which return f"Unexpected error: {e}" to the client. That is the restapi.py:838 pattern, now reachable from an uploaded byte.
  • simple_modbus.py:897 — the shrink warning misses the case it exists for. A legacy-shape config (max_coils, max_holding_registers, …) leaves requested.get("holding_registers", {}) empty so nothing is reported while lines 1018-1031 still clamp, and a config with no buffer_mapping returns at 898 while the 8192/1024 defaults at 1010-1013 are clamped in silence. The old-editor config is exactly what this is for.
  • simple_modbus.py:1105 — the clamp now admits up to 65536 per segment into dense pymodbus blocks: qw + mw + 2*md + 4*ml reaches 524288 [0] * n entries, well past the 65536 addresses a Modbus PDU can reach. It needs the user to configure large counts, but everything above 65536 is unaddressable by construction.
  • journal_buffer.c:100force_map_alloc overwrites g_forced[t] unconditionally, leaking fourteen rows on any journal_init not preceded by journal_cleanup.
  • journal_buffer.c:329 and :356journal_force_set / journal_force_clear still return with no log when out of range; only the bound changed from 1024 to g_force_size. When the map was never allocated g_force_size is 0 and every force is dropped without a trace. Both run under image_lock, not on the lock-free producer path, so a rate-limited warning is affordable — and silent drops are what this change set out to remove.
  • image_tables.h:202image_tables_alloc and image_tables_free are the only image entry points with no documented locking contract, while bind/fill/clear immediately below all state "Caller must hold the image-tables mutex". The two call sites already disagree: plc_state_manager.cpp:1084 locks, plc_main.c:160 does not.
  • image_tables.h:187 and the PR body — the memory figure understates the cost by roughly three times. At 4096 elements on 64-bit, the three BOOL tables are IEC_BOOL *[8], so 64 bytes per element, not 8: 786 KB. The other eleven add 360 KB, and the temp_* backing buffers are also sized at elements and are not counted at all, adding about 272 KB. That is roughly 1.36 MiB, against the stated ~460 KB. The number matters because "the cost is bounded and small" is what carries the square-image decision over per-table sizing.

Nits — the first two static_asserts at image_tables.cpp:57 compare each member against its own declared type, so they cannot catch the drift the comment claims (only the sizeof(g_image) == 14 * sizeof(void *) one does real work); the contract test skips any struct member not spelled IEC_*, which fails open; MAX_BUFFER_SIZE (plugin_runtime_args.py:28) and MAX_TABLE_ELEMENTS (image_config.py) are the same ABI fact written twice with only one of them pinned by a test; image_config.py copies RUNTIME_ROOT, the parse loop and the write-fsync-rename block from retain_config.py verbatim; test_plugin_driver.c is untouched, so the capacity-zero refusal and the alloc-before-init ordering have no C-side coverage at all; the four log_* fields in the ctypes mirror are declared non-variadic while the C side is void (*)(const char *, ...) (layout unaffected, prototype wrong).


Cross-PR — this changes a finding I raised on editor#1093

This PR independently found the same thing I flagged there and calls it "a case nobody had noticed, and it is the common one": generateModbusSlaveConfig materialises 1024 registers / 8192 coils into modbus_slave.json even when the user never opened the Modbus screen. The answer taken here is to clamp in the plugin (RSK05 as clamp-and-warn) rather than to change the editor.

That does resolve the contradiction on Runtime v4 — the client gets exception 02 instead of a plausible zero. Three things follow. The warning that makes the clamp visible is the simple_modbus.py:897 item above, which misses precisely the old-editor shape. The two changes now have to land together, or the editor ships an image.conf nothing reads while modbus_slave.json keeps overstating. And bare metal has no Modbus plugin to clamp, so it is not covered there.

One thing for the Change Record rather than the code: image_sizes_largest collapses the fourteen figures into one, so the demand's "an area with no producers is sized to zero" — which the editor's risk assessment cites as its Data Minimization row — holds on bare metal only. On Runtime v4 every table is as large as the largest. That is a deliberate, well-argued consequence of CON06, but it is not what the assessment currently claims.


On CI. Three checks did run and pass here (Bootloader, Installer scripts, Webserver pytest). The Ceedling suite still has no gate, and blocker 4 means the pytest run that passed did not include the new tests.

JulioSergioFS and others added 4 commits September 10, 2026 08:49
…s (RTOP-284)

All four from review. All four mine.

**The derived floor never contributed.** `image_sizes_derive_floor`
read `ext_strucpp_get_located_vars`, which `symbols_init` populates --
and `symbols_init` runs on the cycle thread, created at
plc_state_manager.cpp:1125, AFTER the load path sizes and allocates the
image at 1079-1085. So the pointer was always null here, the floor was
always a zero vector, and `max(configured, derived)` silently degraded
to "whatever image.conf said". With no image.conf that is capacity 1 for
any program: every located address above index 0 rejected by the bounds
check, no log. Unload nulls the pointer again, so the second load would
not have escaped it either.

That is the whole safety net this function exists to be, and the
acceptance criterion "removing the configuration file by hand still
brings the runtime up, sized by the floor derived from the loaded
program" failed outright.

It now takes the PluginManager and resolves the two accessors itself, so
the answer depends on the program having been dlopen'd -- which the
caller has just done -- rather than on the order two threads happen to
run in.

Worth recording how the testing missed it: `image allocated: 1 elements
per table` in the boot log is correct for boot, and is also exactly what
a program load without image.conf produces. Reading the boot log could
not tell the two apart. Only loading a real program and reading back the
capacity separates them.

**journal_init deadlocked the mutex-fallback build.** It took
g_journal_mutex and then returned -1 on an allocation failure, skipping
the unlock, so every later journal_add, journal_apply_and_clear and
journal_is_initialized would block forever and take the scan thread with
them -- journal_cleanup included, so nothing could recover it. The map
depends on nothing that lock protects, so it is allocated before the
lock is taken. The lock-free variant was already clean.

**The journal write bound truncated at the largest legal image.**
`idx >= (uint16_t)g_buffer_ptrs.buffer_size` yields 0 at capacity 65536
and drops every write with no diagnostic. It is the same wrap the
comment above g_force_size describes, which is why that was widened to
uint32_t -- and this was the one comparison the widening missed. 65536
is reachable: it is the ceiling of the uint16 byte_index in the ABI.

**The twenty-four new tests never ran in CI.** tests.yml passes
`--ignore=tests/pytest/plugins` for pre-existing failures there, and
both new files lived in that directory. They passed locally because
scripts/run-pytest.sh carries no ignore. That is worse than a gap: the
contract test between the C sources and the Python key list was
justified on the grounds that pytest is the only suite CI runs here, so
it is the only guard that would fire -- and at that path it did not.
Moved to tests/pytest/, and verified under the workflow's exact command:
178 tests, the 24 among them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Allocation could not leave the running image half-replaced, an exposure
could not shrink in silence, and a config could not build a block past
what a PDU can address.

image_tables_alloc now builds into locals and publishes only after all
28 allocations succeed, so a failed re-allocation leaves the running
image untouched instead of a mix of old and new tables. image_sizes_read_conf
validates with errno, endptr and IMAGE_MAX_ELEMENTS, and logs what it
ignored. The mutex initialises through pthread_once. Boot allocation
returns EXIT_FAILURE rather than continuing with no image.

Both load rollbacks free the image, and unload calls
plugin_driver_cleanup_init before freeing, because plugin_driver_stop
skips plugins whose running flag is already clear -- those kept the
pointers they copied by value at init.

The Modbus slave's shrink warning missed the two shapes it exists for: a
legacy config (max_coils and friends) and a config with no buffer_mapping
were both clamped without a word, which is exactly the old-editor upload
that makes shrinking possible at all. One function now understands all
three shapes, and the warning compares what was asked against what was
built, so the two cannot drift apart again.

Fitting each segment to the image was not enough either. The register
block composes four segments as qw + mw + 2*md + 4*ml, so segments at
the image ceiling would build 524288 list entries for addresses no PDU
can reach. The composed block is now fitted to one Modbus table,
trimming from the tail so earlier segments keep their addresses.

image_config.py answers a non-UTF-8, directory or unreadable image.conf
with the same sentinel it uses for a malformed one, rather than raising
into the upload handler.

Verified: 46 tests across the three files, ruff clean on the new ones
with no regression on simple_modbus.py, Docker build with no warnings in
any file touched, and the boot path still allocating its minimal image.
The two failures in tests/pytest/modbus_slave and three in plugins/opcua
reproduce on HEAD.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…pe is known

The other end of the editor's format change (DOPE-615 group A). Values now
carry the unit their ADDRESSES use, the three BOOL tables arrive in bits, and
the conversion to the [N][8] shape the storage actually has happens here --
once, in the one place that knows that shape.

    format_version=2
    bool_output=6 bits
    int_output=4 words

The unit is not decoration. bool_output in bytes is a perfectly plausible
number that allocates an image eight times too small, with no diagnostic on
either side: every located address above the first eighth is refused at bind
time, per variable, on a device nobody is watching. A value carrying the wrong
unit is now refused rather than guessed at, at install and again at parse.

format_version is required and must be 2. A file declaring anything else, or
nothing, is ignored WHOLE rather than read by today's rules -- reading a
future format by today's rules is exactly how a unit change becomes a silent
factor of eight. Zeros are not a failure: the floor derived from the loaded
program takes over, the same path a device with no image.conf follows. There
is no branch for version 1, which was written but never merged.

THE CEILING IS IN ELEMENTS AND THE FILE IS NOT, so it is applied after
conversion, on both sides. IMAGE_MAX_ELEMENTS is the uint16 index the ABI
addresses through (CON03) -- a count of table elements, so 65536 elements of
bool_output is 524288 bits. Comparing the raw bit count against the element
ceiling would have refused every legal image above 8192 bytes, eight times
early, by reintroducing the very unit confusion this format removes.

The parser also builds into a local and publishes only once the version checks
out, so a file this runtime cannot read leaves zeros rather than a mixture of
tables it understood and tables it did not.

Tests: the contract test gains the unit column, so a table whose unit
disagrees between the C sources and the webserver fails CI -- verified by
flipping bool_output to bytes and watching it fail. It also pins the format
version across the two implementations. New pytest covers a missing version, a
future version, the wrong unit, a missing unit, and the ceiling at 65536 words
and 524288 bits from both directions. 216 tests pass, which is the suite CI
runs. image_tables.cpp compiles with no new warning.

The editor half is DOPE-615 group A (editor#1093, web#742). Two ends of one
file format: neither ships alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The fourteen image tables stop sharing one number. A project needing four
output words and no analog input gets int_output four long and int_input at
the minimum, instead of both at whatever the largest area needed -- which is
what BR01, FR21 and NFR04 asked for all along and what image_sizes_largest()
was quietly undoing.

HOW THE SIZES REACH A PLUGIN, without moving the struct. plugin_runtime_args_t
carries a single buffer_size and CON06 guarantees pre-compiled plugins keep
their field offsets, so the fourteen travel through a new OPTIONAL symbol the
loader resolves with dlsym -- joining the five optional ones it already looks
up -- and PyObject_GetAttrString for Python, with PyErr_Clear() because a
missing attribute leaves an exception set.

    int set_image_sizes(const uint32_t *sizes, uint32_t count);

Presence IS the declaration: exporting it means "I understand per-table
sizes". Called before init(), because init() is where both native plugins copy
the base pointers by value; delivering afterwards leaves a window per load in
which a plugin holds new pointers and previous sizes.

AND IF ANY PLUGIN LACKS IT, THE RUN STAYS SQUARE. Not a preference: a plugin
bounding a byte index into bool_output and a word index into int_output with
one buffer_size is correct exactly while the tables are equal. The decision is
made per load, before allocating, and logged with the plugin that forced it --
otherwise the two modes are indistinguishable from outside.

buffer_size itself becomes the SMALLEST of the fourteen. Bounding by the
smallest refuses an index; bounding by the largest reads past every shorter
table. Under-permissive is the only safe direction for a consumer that has not
been told the tables can differ.

Three loops that used one length for fourteen tables are now per table:
zero_slots (a memset past the end of the shorter ones -- a heap overflow
written by the function that exists to prevent this class of mistake),
fill_null_pointers (null slots left in the longer tables, which is the state a
plugin dereferences), and the journal's write bound.

THREE ENUMS, TWO ORDERS, found while doing the journal. journal_buffer_type_t
and the s7comm plugin's type each group a width's memory beside its input and
output; image_table_id_t puts every memory table at the end. JOURNAL_INT_MEMORY
is 7 and IMAGE_TABLE_INT_MEMORY is 10, so a cast between them lands writes
under another table's bounds -- and journal_buffer.h says it "matches the
OpenPLC image table types". Both mappings are now written out explicitly and
pinned by a pytest that also asserts the two orders really do still disagree,
so making them identical becomes a deliberate act rather than a discovery.

image_table_id_t moved to its own header so plugin_types.h can reach it
without pulling the runtime internals in. Publishing a type costs no ABI.

In-tree consumers migrated with it: s7comm's eight clamps, EtherCAT's bounds
check, the shared Python validator and the Modbus slave's eight segments each
follow the table they actually address. All of them fall back to buffer_size
when the sizes were never delivered -- without that, an older runtime loading
a newer plugin leaves every table at zero and the plugin refuses everything,
silently.

The composed holding-register block is settled in simple_modbus's own
docstring: the block already dispatches each address to one segment and
therefore one table, so each segment clamps against its own and the block is
the concatenation. The three candidate answers all discard storage the project
asked for. The consequence for a client -- a segment's start address moves
when a table before it shrinks -- was already true whenever a count changed.

Deliberately NOT here: the deprecation attribute on buffer_size. The build
carries -Werror, so it would fail every consumer still reading it rather than
naming them. It goes in once the VPP packages are migrated, which is that
repository's own task.

Verified: 225 pytest, which is the suite CI runs; every changed translation
unit clean under -Wall -Wextra -Werror, including both journal build variants;
s7comm and EtherCAT compiled against their real headers, with EtherCAT's two
-Waddress warnings unchanged from HEAD. The container build compiles the core
successfully and then fails initialising submodules, which is the worktree's
.git not existing inside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes B2. The sizes array a plugin receives through set_image_sizes is
indexed by image_table_id_t, and plugin_types.h is the header plugins
actually include -- plugin_driver.h is the loader's, which a plugin never
sees. Without this a plugin had to count positions instead of naming them.

Publishing a type costs no ABI: no struct gains a field and no offset moves.

A plugin built against an older runtime will not find the header, so anything
that must work against both -- a VPP package, built on the device against
whatever runtime is there -- carries its own constants in the documented
order instead. The comment says so, because the obvious next step is to
include this from a package and that is the one place it must not be done.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@marconetsf marconetsf left a comment

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.

Reviewed across three axes: the allocation lifecycle and memory safety, the journal and concurrency, and the Python side with the ABI mirror.

Severity on every point, here and inline: 🔴 Major (must fix before merge) · 🟡 Changes required (should be addressed) · 🟢 Nit (optional) · ❓ Question. Each carries a confidence out of ten.

One 🔴 Major, five 🟡 Changes required and one ❓ Question, all inline. The mechanical half of the change holds up well and I want to be specific about what I checked rather than leave it implied:

  • The ctypes mirror was measured, not just read. I compiled a probe against the unmodified plugin_types.h and printed offsetof for all 39 members, then imported shared.plugin_runtime_args and printed every field's .offset. Identical, and sizeof is 552 on both sides. Nothing inserted, reordered, resized or removed, and both 10000 caps are gone with none left anywhere in the Python plugin tree. Given that nothing in CI checks this, it seemed worth doing properly.
  • All-or-nothing allocation verified line by line. All 28 callocs are issued, then checked as one conjunction; on failure all 28 locals are freed and the function returns without touching g_image, the temp_* buffers or g_capacity. No double free, no leak on the failure path.
  • The derived floor is right: byte_index + 1 on a uint16_t, no off-by-one, uniform across BOOL and the rest, an empty program yields zero, and nothing assumes a minimum. max(configured, derived) is genuinely a max with the units reconciled first.
  • The remaining sizeofs are correct and the three static_asserts do still pin the shape.
  • All three new pytest files are actually run by CI — none landed in one of the three ignored directories, and their docstrings show that was deliberate.
  • No allocation on the scan path, nothing in the diff reachable from a signal handler, JBUF_FORCE_SIZE fully gone, no new str(e) leak, and the upload route is @jwt_required().

Two things about the environment that are worth stating rather than leaving to inference. The mutex journal variant is dead code: JOURNAL_FORCE_MUTEX is defined nowhere in the repository and ATOMIC_INT_LOCK_FREE == 2 on every supported target, so every build compiles the lock-free path. The asymmetries between the two variants are real but unobservable — and equally, nothing will catch a future divergence in that half. And Ceedling still does not run in CI, so the allocation work, whose whole risk lives in the second program load, has no automated gate. The three green jobs are real (tests.yml does have a pull_request trigger now) but they do not cover any of this.

return 0;
}

static void force_map_free(void)

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.

🔴 Major · confidence 9/10 · use-after-free reachable from a plugin thread

Moving the forced-slot map from static storage to the heap gives it a lifetime it never had, and two things combine badly.

First, the ordering inside this function is backwards. The rows are freed and NULLed, and only afterwards are the guards cleared:

for (int t = 0; t < JOURNAL_TYPE_COUNT; t++)
{
    free(g_forced[t]);
    g_forced[t] = NULL;
}
g_force_size  = 0;
g_force_count = 0;

is_slot_forced checks exactly those two variables before indexing:

if (g_force_count == 0) return 0;
if (type >= JOURNAL_TYPE_COUNT || idx >= g_force_size) return 0;
...
return g_forced[type][idx] != 0;

A reader that passes both guards because they have not been cleared yet then dereferences a NULL row.

Second, journal_cleanup() runs while plugin threads are still live. In plc_state_manager.cpp it sits four lines above plugin_driver_stop(plugin_driver), and the comment just above it already acknowledges plugins are alive at that point ("a plugin-backed store has to still be alive to answer"). image_lock is handed to every plugin as args->image_lock and calls journal_apply_and_clear(), so is_slot_forced runs on plugin-owned threads: EtherCAT's per-master ecat_bus_thread calls it at ethercat_plugin.c:921, and the s7comm server callback at s7comm_plugin.cpp:1088. journal_cleanup itself takes no lock.

So: stop the PLC with at least one variable forced (g_force_count is non-zero only then), and a plugin thread can be inside is_slot_forced reading a row that was just freed. Forcing a variable from the debugger or through OPC UA is an ordinary operation, so this is reachable rather than theoretical.

Both halves of the fix are worth doing:

  1. In this function, set g_force_count = 0; g_force_size = 0; before the free loop. A reader that observes either guard cleared never indexes a row, which closes the NULL dereference on its own.
  2. Move journal_cleanup(); debug_write_journal_reset(); to after plugin_driver_stop(plugin_driver). That is the only thing that guarantees no plugin thread is inside image_lock(), and it also closes the same-shaped race between the memset(&g_buffer_ptrs, 0, ...) in cleanup and apply_write_raw reading g_buffer_ptrs.buffer_size.

Comment thread core/src/plc_app/plc_state_manager.cpp Outdated

pthread_mutex_t *itm = image_tables_mutex();
pthread_mutex_lock(itm);
const bool ok = image_tables_alloc(image_sizes_largest(&configured));

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.

🟡 Changes required · confidence 8/10 · the image is gated on a plugin concern

This whole sizing and allocation block sits inside if (plugin_driver), which opens at line 1034 and closes at 1133. The image is the program's storage, not the plugins' — it is where located variables live — so it should not be conditional on there being a plugin driver.

plugin_driver is the global set once in plc_main.c. If plugin_driver_create() returns NULL, plc_main.c simply skips its entire block: no error, no exit. The same is true one level down, where the boot-time image_tables_alloc(0) is nested inside if (plugin_driver_load_config(...) == 0) and the else only logs "Failed to load plugin configuration" and falls through.

A later START then reaches load_plc_program with plugin_driver == NULL, skips this block, and g_capacity stays 0 for the life of the process. Downstream: image_tables_fill_null_pointers() loops zero times, journal_init gets fourteen NULL bases and buffer_size == 0, journal_add rejects every write, and threaded_image_read returns zero for every located variable. The PLC scans and drives nothing, silently — which is the failure plc_main.c explicitly refuses to ship a few lines above the boot allocation ("a runtime that looks alive and drives nothing").

Suggest hoisting this block above line 1034, out of the if (plugin_driver).

Comment thread core/src/plc_app/plc_state_manager.cpp Outdated
/* Released only after every plugin has been stopped AND de-initialised
* above. Both native plugins cache these pointers by value at init(),
* so freeing any earlier hands them memory that belongs to nobody. */
image_tables_free();

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.

🟡 Changes required · confidence 8/10 · only half of the ordering is asserted

The acceptance criterion asks for the ordering to be asserted, not merely done, and the allocate-before-init half does that properly — plugin_driver.c:1063 is a real runtime check with a comment explaining it is deliberately not assert() because it has to hold in the field.

The release-after-stop half has no equivalent. image_tables_free() is protected only by the comment above it. plugin_driver_cleanup_init already returns the count it cleaned and that return value is discarded here, so a refactor that moves this call above line 1234 gets no diagnostic at all — which is the exact class of regression the other half guards against. It is also not a purely hypothetical gap: cleanup_init skips any plugin whose initialized is 0, and on the native branch a plugin with no cleanup symbol keeps its by-value g_runtime_args copy with no way for the runtime to know.

Suggest using the returned count against a live count, or adding a plugin_driver_any_initialized(driver), and refusing the free with a log_error when a plugin is still initialised — mirroring the guard in generate_structured_args_with_driver.

Comment thread core/src/plc_app/journal_buffer.c Outdated
* was never allocated g_force_size is 0 and EVERY force lands here.
* Both force paths run under image_lock rather than on the lock-free
* producer path, so a log line is affordable. */
log_warn("Journal: force ignored, type %u index %u outside the image (%u slots)",

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.

🟡 Changes required · confidence 8/10 · logging on the real-time path, under the image lock

Replacing the silent return was the right instinct — dropping a force with no diagnostic is the bug being fixed. The placement is the problem.

The comment argues "both force paths run under image_lock rather than on the lock-free producer path, so a log line is affordable". Being under image_lock is what makes it not affordable. The only caller is apply_located, reached from debug_write_journal_drain(), which runs inside image_lock() ... image_unlock() on plc_cycle_thread — the thread that called set_realtime_priority().

log_warn takes log_mutex, a plain PTHREAD_MUTEX_INITIALIZER with no priority inheritance (unlike the image mutex, which is built through init_recursive_pi_mutex precisely because it needs it), and then does a blocking socket write(). So the SCHED_FIFO dispatcher can block behind a low-priority logging thread while holding the image lock, which stalls every plugin thread waiting on that lock. It is also unrate-limited: one line per offending entry, up to DBGW_MAX_ENTRIES = 128 per drain, and an OPC-UA client repeatedly writing one bad address reproduces it every tick.

Suggest keeping the guard but counting instead of logging — a static unsigned g_force_oob_drops reported from off the RT path, or the rate-limited pattern already used elsewhere in plc_state_manager.cpp. Better still, give journal_force_set/journal_force_clear an int return so apply_located can propagate the refusal back to the debugger or OPC-UA client, which is where the person who asked for the force is actually looking.

.buffer_size = (int)image_tables_capacity(),
.image_mutex = itm,
};
if (journal_init(&journal_ptrs) != 0)

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.

🟡 Changes required · confidence 8/10 · a newly realistic failure is logged and ignored

if (journal_init(&journal_ptrs) != 0)
{
    log_error("Failed to initialize journal buffer");
}
else
{
    log_info("Journal buffer initialized");
}

No return, no ERROR state. That was tolerable while journal_init could only fail on a NULL argument — a programming error caught at first boot. This PR adds force_map_alloc to both journal_init variants, so it now fails on allocation pressure at runtime, and that path leaves g_initialized == false.

With g_initialized == false, every journal_add returns -1 and journal_apply_and_clear returns immediately. That is not only "plugins cannot write": the program's own copy-out goes through the same journal (image_tables.cpp calls journal_write_bool / journal_write_byte there). So the runtime proceeds to plugin_driver_start, takes real-time priority, spawns the task threads and publishes RUNNING with every located output permanently dead, and one log_error line to say so.

That is the same class of silent failure this task set out to remove, one layer up. Suggest treating it as fatal and matching the image-allocation branch this PR itself added further down ("log and stop, never a partial image"): publish ERROR and return from plc_cycle_thread.

Comment thread webserver/image_config.py Outdated
# Left as a parse failure rather than an exception: a
# garbled line should produce the same clear refusal as an
# out-of-range one rather than a traceback from the parser.
sizes[key] = -1

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.

🟡 Changes required · confidence 9/10 · the refusal message describes the wrong mistake

read_image_conf_file stores -1 as the sentinel for an unparseable count:

try:
    sizes[key] = int(count)
except ValueError:
    sizes[key] = -1

validate_table_count then always receives an int, so its except (TypeError, ValueError) branch — the one that says "must be a whole number of {expected}" — is unreachable from the file path, and the -1 falls into the count < 0 branch instead. Running the real module:

int_output=4.5 words                  -> int_output cannot be negative (got -1).
int_output=<5000 digits> words        -> int_output cannot be negative (got -1).

The refusal is correct and nothing escapes, but the message is the entire point of validating here rather than in the core — this module's own docstring says refusing it here is the only place a person sees it. Someone who wrote 4.5 goes looking for a minus sign that is not there, and a completely different mistake (Python's int-from-string digit limit) produces the same sentence.

One-line fix: make the sentinel None instead of -1. int(None) raises TypeError, the existing handler fires, the message becomes "int_output must be a whole number of words.", and the negative branch goes back to meaning an actual negative.

* stopping the running ones and freeing would leave it pointing at
* released memory. cleanup_init undoes init() for every plugin,
* started or not, which is what actually ends the last reference. */
plugin_driver_cleanup_init(plugin_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.

❓ Question · confidence 6/10 · raising this for the EtherCAT owner rather than as a blocker

Adding plugin_driver_cleanup_init here is necessary for the free below to be safe, and the reasoning in the comment above it is right. The side effect is that from the first STOP onwards every plugin is de-initialised, where previously they stayed initialised until the next load's plugin_driver_update_config.

plugin_driver_execute_command does not gate on that — it dispatches on plugin->native_plugin && plugin->native_plugin->execute_command with no plugin->initialized check, and native_plugin survives cleanup_init since only teardown_plugin_instance frees it. EtherCAT's cleanup() frees g_masters, nulls it and zeroes g_master_count, but leaves its cached g_runtime_args untouched.

So PLUGIN_CMD handlers issued while the PLC is stopped — which is exactly when the editor scans slaves and reads status — now run against torn-down state. I traced the dispatch but not every handler: scan and list-interfaces look bus-level and independent of g_masters, so I could not demonstrate a crash, but status and diagnostics iterate g_masters and will report nothing where they previously reported the configured masters. The comment a few lines above also notes EtherCAT is "deliberately init'd even when disabled", and this change ends that state at every stop.

Is that intended? If the editor-side commands do need the plugin initialised, either gate plugin_driver_execute_command on plugin->initialized and re-init on demand, or scope the de-init to the plugins that actually cached the image.

JulioSergioFS and others added 8 commits September 14, 2026 16:52
… runtime

Marcone's review on #195. One blocker and five changes required.

USE-AFTER-FREE REACHABLE FROM A PLUGIN THREAD, in two halves. force_map_free
freed the fourteen rows and only then cleared g_force_count and g_force_size
-- the two variables is_slot_forced checks before indexing -- so a reader that
passed both guards dereferenced a NULL row. And journal_cleanup() ran four
lines BEFORE plugin_driver_stop, with plugin threads still live: image_lock is
handed to every plugin as args->image_lock and calls journal_apply_and_clear,
so is_slot_forced runs on EtherCAT's bus thread and the s7comm callback.
Forcing a variable from the debugger is all it takes for there to be anything
to read.

Both halves fixed: the guards go down before the free, and the cleanup moves
after the stop. plc_retain_flush() stays before the stop, because a
plugin-backed store has to be alive to answer, so the two now sit on opposite
sides of it deliberately.

THE IMAGE IS NO LONGER GATED ON A PLUGIN CONCERN. Sizing and allocation sat
inside `if (plugin_driver)`, and plc_main.c skips its whole plugin block when
plugin_driver_create() returns NULL -- no error, no exit. A later START then
left capacity at zero for the life of the process: fill_null_pointers looped
zero times, journal_init got fourteen null bases, every journal write was
rejected and every located read returned zero. The PLC scanned and drove
nothing, silently, which is the failure plc_main.c refuses to ship a few lines
above its own boot allocation. The block is hoisted out, still after
plugin_manager_load and before plugin_driver_init.

A FAILED journal_init NOW STOPS THE RUNTIME. It could only fail on a NULL
argument before; it allocates the forced-slot map now, so it fails under
memory pressure at run time and leaves g_initialized false. Every journal_add
then returns -1 -- including the program's own copy-out, which goes through the
same journal -- so carrying on published RUNNING with every located output
permanently dead and one log line to say so.

FORCES OUT OF RANGE ARE COUNTED, NOT LOGGED. Saying it out loud was the right
instinct and the wrong place: both force paths run under image_lock on the
real-time thread, and log_warn takes a mutex with no priority inheritance
before a blocking socket write, so a SCHED_FIFO dispatcher could block behind
the logging thread while holding the image lock. Unrate-limited too, at one
line per entry. The count is reported once per cycle from outside the lock,
and reading it clears it.

THE RELEASE HALF OF THE ORDERING IS CHECKED. Allocate-before-init is a real
runtime refusal; release-after-stop had only a comment, so a refactor moving a
cleanup_init below the free got no diagnostic while both native plugins hold
the base pointers they copied by value at init(). A new
plugin_driver_any_initialized() guards all three free sites.

AND THE REFUSAL MESSAGE NAMES THE RIGHT MISTAKE. An unparseable count was
stored as -1, so `int_output=4.5 words` was refused with "cannot be negative
(got -1)" and the reader went looking for a minus sign that is not there. The
sentinel is None, which reaches the handler that says "must be a whole number
of words" -- and refusing it where someone is watching the build log is this
module's whole reason for existing.

219 pytest; every changed TU clean under -Wall -Wextra -Werror, both journal
variants included.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s://github.com/Autonomy-Logic/openplc-runtime into RTOP-284-B-per-table-allocation

# Conflicts:
#	core/src/plc_app/plc_state_manager.cpp
… mappings

Marcone's review on #196. One blocker and three changes required.

THE FEATURE SHIPPED INERT, which is the blocker and the one that makes
everything under it untested rather than merely unverified. The runtime keeps
the image square for any run in which even one loaded plugin lacks
set_image_sizes -- correctly -- and plugins_default.conf ships five, of which
only simple_modbus declared it. So image_sizes_flatten ran on every load of a
stock device, and the two enum-order fixes, the three loop fixes and the
s7comm/EtherCAT clamps all ran in the degenerate case where they cannot differ
from the old behaviour.

modbus_master and opcua now declare it. Nothing else was needed: both already
bound through SafeBufferAccess -> BufferValidator, which this branch migrated
to the table each buffer lives in.

A test reads plugins_default.conf and asserts every shipped plugin declares
it -- Python by import, native by linking plugin_image_sizes.c. Verified it
fails on the state that shipped. Nothing failed before: the image was simply
square, which is also what a correct square run looks like, and this is the
only thing that tells the two apart.

A DEGRADED PLUGIN NO LONGER GETS A VOTE. It failed to load, so
plugin_driver_init skips it in every branch: it never receives runtime args
and never touches the image. Letting it answer "no" meant one box missing
Npcap, where EtherCAT degrades, silently cost every other plugin its per-table
image. Disabled plugins still vote, because init runs for them regardless.

THE FORCE BOUND AND THE WRITE BOUND AGREE AGAIN. force_map_alloc sizes every
row to the LONGEST table so each type has somewhere to record, but
journal_force_set validated only against that row length while apply_write_raw
and is_slot_forced validate against the table's own. While every table had one
length the two were one number; they can now disagree. With bool_output at 1
and int_output at 100, forcing bool_output index 5 passed, flipped the bit and
incremented g_force_count -- permanently disabling the fast path -- while both
readers refused it. A force that did nothing and said nothing, which is
exactly what the guard exists to report. Both force paths now check the table
as well as the row.

AND THE THREE REMAINING MAPPINGS ARE PINNED. kJournalToImageTable was checked;
s7_image_table, ecat_table_for and SEGMENT_TABLES were not, and Ceedling does
not run in CI so nothing compile-checks the two C ones either. The contract
test now extracts all three by regex and asserts name-to-name correspondence
and completeness -- SEGMENT_TABLES for all eight entries, where the
behavioural tests happened to exercise three. Verified each catches a
deliberately corrupted entry.

The fourth copy of the table order is pinned with them:
shared/image_sizes.py's IMAGE_TABLE_ORDER is what turns the runtime's
positional array into names, and it joins the existing parametrize rather than
getting a test of its own.

238 pytest; every changed TU clean under -Wall -Wextra -Werror, both journal
variants included.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… came from

Marcone's nits on #196, plus the question he flagged without calling it a bug.

THE JOURNAL BOUNDS BY ITS OWN SNAPSHOT. apply_write_raw read the LIVE image
sizes while the table pointers beside it were captured at journal_init, so the
bound and the pointers came from two different moments. They do not diverge
today -- the image is allocated before the cycle thread that calls
journal_init exists, and a re-load stops that thread first -- but the coupling
was implicit, and implicit is what this whole task keeps finding. The fourteen
lengths are now captured with the pointers, in the journal's own order through
an exported journal_type_to_image_table() rather than a second copy of the map.

A HALF-DELIVERED SIZE MAP IS NO LONGER LEFT BEHIND. set_image_sizes built into
_sizes as it parsed and returned -1 on a bad entry, leaving the tables before
the failure answering and the rest falling back. _sizes is process-global --
imported once per interpreter, shared by every Python plugin in the process --
so a plugin being torn down could leave that for plugins still running. It now
builds into a local and publishes only on success.

AND THREE COMMENTS THAT WERE WRONG:

- plugin_driver.c carried the third copy of the buffer_size contract, still
  saying all fourteen tables are allocated at the same count and pointing at
  image_sizes_flatten. Every clause was false. It now says what the other two
  say: the minimum is the only safe single number for a consumer that has not
  been told the tables can differ.

- The deferral of the deprecation attribute was justified with "-Werror".
  -Werror IS set, in core/src/CMakeLists.txt -- but only for the runtime core.
  The plugins are configured by their own cmake invocation and the VPP
  packages by a plain Makefile, so the attribute would warn there rather than
  fail. The real reason to defer is that the field is still the RIGHT thing to
  read: on a square run it is the length every table has, and it is the only
  bound a plugin that has not adopted the symbol can use. Deprecating now would
  warn at correct code.

- "BUFFER_TYPE_INT_MEMORY is 7" was off by one: s7comm_config.h starts at
  BUFFER_TYPE_NONE = 0, so it is 8. Seven is the journal's. "8 here, 7 in the
  journal, 10 in the image" is the stronger sentence anyway -- three numbers
  for one table is the whole argument.

Also: PyErr_Clear() on the other three optional Python lookups, so the comment
claiming every optional lookup clears it is true rather than aspirational -- a
plugin defining set_image_sizes but not cleanup left an AttributeError set on
exit. And #undef N moved to just after the last use instead of sitting inside
a runtime branch, where it worked only because every use happened to be above
it.

239 pytest; every changed TU clean under -Wall -Wextra -Werror, both journal
variants and s7comm included.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The required half of this finding was done in the previous commit -- the
out-of-image force is counted rather than logged on the real-time path. This is
the half that was left: the refusal went nowhere, so nothing acted on it.

`journal_force_set` and `journal_force_clear` now return int, and `apply_located`
uses it. The two pins it sets have to agree or the debugger lies: the image slot
can refuse -- address outside the table, or the forced-slot map never allocated
-- while `ext_strucpp_debug_set` always accepts, because the IECVar exists
whatever the image is sized to. Pinning the IECVar first showed the variable as
FORCED in the editor and over OPC UA while the image slot took nothing, so the
program kept driving it and the displayed value was fiction. A refusal the user
is told is a success is worse than the refusal.

So the image goes first and the program view only follows a force that landed.
UNFORCE keeps releasing both regardless: a refused clear means the slot cannot
have been forced, and holding the IECVar pinned because of it would strand the
variable forced with no way to release it.

Not propagated further back, and that is a limit rather than an omission. The
write was enqueued by `runtime_external_write`, which returned to its caller a
cycle before the drain runs, so there is no response channel left from here.
Leaving the variable visibly unforced is the only honest signal this path still
owns -- and it is the one the person who asked for the force is looking at.

Both translation units compile clean under the core's own flags, -Werror
included.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The header already documented `table_sizes[]` as the snapshot taken at
journal_init "from the SAME moment as the pointers beside it", and gave the
reason: reading the live image sizes while holding pointers captured earlier
would, if the two ever diverged, apply a new length to an old allocation. Only
`journal_longest_table` actually did that. `journal_type_capacity` -- the one on
the drain path, and the one the review asked about -- still called
`image_table_capacity()`, which reads `g_sizes`, a global `image_tables_alloc`
rewrites under the image-tables mutex this path does not hold.

So the intent was written down and the hot-path function never followed it.
Every caller uses the result to index one of the snapshot's pointers, so taking
the length from a later moment than the allocation it bounds is how a dropped
write becomes an out-of-bounds index instead.

Not a live bug, and the comment says so: the image is allocated before the
cycle thread that calls journal_init exists, and a re-load stops that thread
first. It is the implicit coupling made explicit, which is what the rest of
this task has been doing -- and it turns a non-inlinable cross-TU call per
journal entry on the drain path back into the struct field read it used to be.

Also merges #195, whose refused-force change touches the same two functions.
Both per-table bounds survive the merge intact.

Verified: journal_buffer.c, debug_write_journal.cpp, plc_state_manager.cpp and
image_tables.cpp all compile clean under the core's own flags, -Werror
included; 59 pytest contract tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…cation

feat(image): allocate each table at its own length, told to the plugins
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants