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 9 commits into
developmentfrom
RTOP-284-allocate-the-io-image-on-program-load
Open

feat(image): allocate the I/O image on program load instead of a fixed BUFFER_SIZE#195
JulioSergioFS wants to merge 9 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 3 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>
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