Skip to content

feat(ethercat): run the EtherCAT master as EtherDOG, a supervised process [RTOP-296] - #205

Merged
thiagoralves merged 23 commits into
developmentfrom
RTOP-296-etherdog-split
Oct 6, 2026
Merged

thiagoralves merged 23 commits into
developmentfrom
RTOP-296-etherdog-split

Conversation

@thiagoralves

@thiagoralves thiagoralves commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Second part of RTOP-296. The EtherCAT master no longer runs inside plc_main. It is now EtherDOG, a separate process that the webserver starts and supervises. The runtime talks to it only through EtherDOG's documented protocol: JSON lines on a control socket, and one datagram each way per bus cycle.

Stacked on #203. Merge that first; this PR will then retarget to development.

  • core/src/drivers/plugins/native/ethercat/ is now a thin client plugin (etherdog_link.c, ethercat_iomap.c, ethercat_plugin.c).
    • On start: load the staged bus configuration into EtherDOG, start the bus, read the layout, join it with ethercat_iomapping.json, open the data session. The bus and the program always start together.
    • A relay thread publishes %I through the journal and answers every input frame with %Q read under image_lock.
    • It reconnects on its own if EtherDOG goes away.
    • A mapping mismatch (missing entry, wrong direction, wrong width) fails the start with the entry named.
  • webserver/etherdog_manager.py:
    • spawns and supervises EtherDOG:
      • restarts it immediately after an unexpected exit;
      • disables it with a warning when it cannot start (missing Npcap or another library, bad command line, not installed) or after 3 exits in 30 s;
      • doesn't restart it after a clean exit (Ctrl+C);
      • kills a leftover EtherDOG from an earlier webserver.
    • writes the /run/runtime/etherdog.json session file (0600, O_NOFOLLOW): control endpoint, data transport, bus config path, or the disable reason;
    • on upload, stages conf/ethercat_busconfig.json and stops the bus;
    • forwards EtherDOG's logs into log_runtime.socket.
  • Access: control is AF_UNIX on Linux and Windows. EtherDOG checks each peer's uid (SO_PEERCRED), so the runtime uses no token.
  • The runtime runs without EtherDOG. Only EtherCAT is lost, and the PLC log says why.
  • Discovery routes (scan, test, status, diagnostics, list-interfaces) talk to EtherDOG directly, so they work with no program loaded. plugin_state is kept for the Editor.
  • Legacy uploads: an upload whose single conf/ethercat.json describes masters is rejected before anything on the device changes. The empty file older Editors always write is ignored.
  • Removed: the SOEM submodule and the in-process master sources (config, master, iface_state, proc, io), with their C tests (they moved to EtherDOG). install.sh builds EtherDOG into build/etherdog.
  • Docs: docs/ETHERCAT.md, plus a pointer in CLAUDE.md.

The Editor side is DOPE-657 in openplc-editor and openplc-web: runtimes from v4.3.0 get the split files, older or unknown ones the legacy file. This runtime needs VERSION v4.3.0 at release for that gate to pick the split format.

Ticket

RTOP-296 (epic RTOP-295). Editor side: DOPE-657.

The assessment signatures (author, technical reviewer, project manager) gate the merge. Both documents were written after implementation, with pass 1 and pass 2 consolidated.

How it was tested

On an SLM-RP4 (arm64, PREEMPT_RT) with a Beckhoff EK1818 (8 DI / 4 DO), running an image built from this branch with host networking and --privileged, as the bootloader runs it:

  • Upload through the real Editor UI: openplc-web dev:local with the local-runtime proxy to the device. Clean build and upload, then:
    • the runtime logged Found 2 config files: ['ethercat_busconfig', 'ethercat_iomapping'];
    • the PLC is RUNNING;
    • the debugger shows live values;
    • the Runtime Status screen shows the EtherCAT table: OPERATIONAL, 1 slave, 1 kHz, 0 WKC errors.
  • Outputs reach the bus: EtherDOG's output image alternates 0x05 / 0x06, exactly as the program's out1..out4 logic dictates. 30k+ frames, 0 dropped, 0 watchdog trips.
  • Cycle time comes from the config: with cycle_time_us 1000 the wire and the relay both ran at 1000/s; with 2000 both ran at 500/s (period 1947–2060 µs).
  • EtherDOG killed with -9: restarted in about 2 s and the busconfig reloaded. The plugin logged EtherDOG link restored, and plc_main kept its PID and stayed RUNNING.
  • C unit tests at the time: tests/test_ethercat_iomap.c and tests/test_ethercat_iec_location.c, 9 + 19 passed, built with Unity.
  • pytest at the time: the CI set passed (160), and the known-green plugin suites passed (26).
  • Linux container build: plc_main, the client plugin (no SOEM or pcap linkage) and build/etherdog all build.

After the review fixes (c388cde, EtherDOG 462122a):

  • Ceedling: the EtherCAT suites pass, 43 tests in 4 suites, including a new link-client suite and a relay test against a fake EtherDOG.
  • pytest: the CI set passes (166 passed, 7 skipped).
  • EtherDOG: 9 suites pass on Linux and 8 on MSYS2. A foreign uid is refused on Linux.
  • Not re-run on the SLM-RP4: the device was unreachable. The hardware results above predate these fixes.

After the cross-repo review (560f83b, c1f4bb0), on the SLM-RP4:

  • EtherDOG delayed 8 s at boot: the PLC reached RUNNING first, the plugin logged one retry warning, and the link came up when EtherDOG did.
  • Upload with the PLC running: the upload stopped the PLC before processing any file, and the bus stayed stopped through the compile. The new program then started with its own bus configuration (2000 us cycle).
  • Ceedling: the EtherCAT suites pass, 46 tests.
  • pytest: the CI set passes (173 passed, 7 skipped).

Windows (MSYS2), on a Parallels VM with no EtherCAT hardware:

  • install.sh builds the runtime and EtherDOG.
  • EtherDOG's unit suites pass.
  • A missing Npcap disables EtherDOG after one attempt.

Not tested: EtherCAT on real hardware under Windows.

Known, not from this PR: under Ceedling, tests/support/debug_handler_mocks.c fails to link any test that doesn't pull in utils.c.

Checklist

  • bash scripts/run-pytest.sh CI subset passes
  • pre-commit run clean (hooks skipped; they reformat unrelated files)
  • Docs updated (docs/ETHERCAT.md, CLAUDE.md, README.md)
  • Follows docs/pr-reviews/PR_REVIEW_CHECKLIST.md

🤖 Generated with Claude Code

https://claude.ai/code/session_01V8oSX7QRK2xfkLVepyMs58

…cess

The SOEM plugin that ran inside plc_main is replaced by a thin client. The
master itself is EtherDOG, started and restarted by the webserver and
driven over its documented control socket and per-cycle datagrams.

- ethercat plugin: joins EtherDOG's layout with ethercat_iomapping.json,
  relays one frame each way per bus cycle (journal for %I, image_lock for
  %Q) and reconnects on its own if EtherDOG restarts
- webserver: etherdog_manager.py (spawn, supervise, token, session file,
  busconfig delivery), discovery routes talk to EtherDOG directly, and an
  upload carrying the old single ethercat.json is rejected
- install.sh builds EtherDOG into build/etherdog; the SOEM submodule and
  the in-process master sources are gone

Refs: RTOP-296

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8oSX7QRK2xfkLVepyMs58
First runtime with the EtherCAT master in EtherDOG; editors emit the split
EtherCAT configuration from this version on.

Refs: RTOP-296

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8oSX7QRK2xfkLVepyMs58

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

[required] Same as #203: Requirements Gathering (RTOP-297) and the Cybersecurity Risk Assessment (RTOP-298) need to be written, second pass included, and linked before this merges to development. AC9–AC12 are also still open.


[minor] Test coverage: nothing covers frame parsing in etherdog_link.c, relay reconnect/stop, _upload_has_legacy_ethercat through the real handle_upload_file path, or the apply_busconfig ordering. CI doesn't build the C code or run Ceedling, so test_ethercat_iomap.c and test_ethercat_iec_location.c are only verified locally.


[question] Two things on the EtherDOG side, outside this repo:

  • the control socket doesn't check SO_PEERCRED, which RTOP-296 asks for;
  • EtherDOG's LICENSE is GPLv3, while the runtime becomes MIT in #203.

Is the licensing split intentional, and is the process boundary what keeps it clean?


[nit] A few small ones:

  • ethercat_iomap.c and etherdog_link.c redefine constants instead of including journal_buffer.h / etherdog_protocol.h;
  • etherdog_link.c:246 silently truncates replies larger than response_size, and the error only shows up later as "not JSON";
  • _write_private doesn't use O_NOFOLLOW (low risk, since /run/runtime is root-owned).

webserver/app.py:616: [minor] Nothing calls etherdog_manager.stop() in the shutdown path. On native installs EtherDOG is left orphaned holding the NIC, since it has no PDEATHSIG. stop() also calls proc.kill() without a wait().

Comment thread webserver/app.py
Comment thread core/src/drivers/plugins/native/ethercat/etherdog_link.h Outdated
Comment thread core/src/drivers/plugins/native/ethercat/ethercat_iomap.c
Comment thread core/src/drivers/plugins/native/ethercat/ethercat_iomap.c Outdated
Comment thread install.sh
Comment thread core/src/drivers/plugins/native/ethercat/ethercat_plugin.c Outdated
Comment thread webserver/etherdog_manager.py Outdated
Comment thread webserver/app.py
Comment thread webserver/app.py
Comment thread core/src/drivers/plugins/native/ethercat/ethercat_plugin.c Outdated
Base automatically changed from RTOP-296-mit-license-headers to development September 24, 2026 13:06
thiagoralves and others added 9 commits September 24, 2026 09:13
pacman -Sy followed by -S cmake installed a cmake linked against a newer
jsoncpp than the one on disk (msys-jsoncpp-27.dll missing). Skip pacman when
every package is present, otherwise upgrade with -Syu, and repair a cmake that
does not run.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8oSX7QRK2xfkLVepyMs58
…indows

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8oSX7QRK2xfkLVepyMs58
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8oSX7QRK2xfkLVepyMs58
The runtime runs without EtherDOG; only EtherCAT is lost. The supervisor now:
- restarts EtherDOG as soon as it exits instead of after a growing backoff;
- disables it at once when it can never start (missing Npcap or another
  library, bad command line, cannot be executed, not installed);
- disables it after 3 exits within 30 s, the runtime's safe-mode policy;
- retries on the next program upload.

The reason goes in the session file, and the plugin logs it as a warning
and skips EtherCAT rather than failing to start.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8oSX7QRK2xfkLVepyMs58
Ctrl+C reaches EtherDOG too, and it exited 0 before the supervisor knew the
webserver was stopping, so it was restarted. A clean exit is now never
restarted, and the webserver stops EtherDOG on shutdown.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8oSX7QRK2xfkLVepyMs58
It held the control port, so every new EtherDOG exited and was disabled.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8oSX7QRK2xfkLVepyMs58
A new token on every spawn made a leftover EtherDOG impossible to shut down.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8oSX7QRK2xfkLVepyMs58
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8oSX7QRK2xfkLVepyMs58
- The bus configuration is staged at upload and loaded by the plugin when
  the program starts, so the bus and the program always start together.
- Control replies have no fixed size (up to 64 MB); a reply too large for
  execute_command's buffer is an error instead of being cut off.
- IEC byte indexes above the 16-bit journal range are rejected, as are
  duplicate master names and entry counts past the bound array.
- Masters with no I/O mapping are ignored with a warning.
- Bus and link losses are logged as PLC warnings; the program keeps running.
- The relay runs at the bus thread's task_priority.
- Control goes over AF_UNIX on every platform, checked by EtherDOG's peer
  credentials; the token is gone. Leftover EtherDOG processes are killed
  at startup.
- EtherDOG stderr errors are logged at error level until it is up; a
  killed EtherDOG is reaped; the session file is written with O_NOFOLLOW.
- The legacy-upload message names runtime 4.3.0 and Editor 4.3.2.
- Tests for the link client, the relay against a fake EtherDOG, the
  upload path and the new validations; drop the stale SOEM test stubs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8oSX7QRK2xfkLVepyMs58
@thiagoralves

Copy link
Copy Markdown
Contributor Author

Replying to the [question] in the review:

Licensing split: yes, it's intentional. EtherDOG is its own program (GPL-3.0-or-later, built on SOEM, in its own repository), and the runtime stays MIT. The process boundary is what keeps them apart:

  • They are separate executables. There's no linking, no shared memory and no shared source.
  • They talk over a generic protocol documented in EtherDOG's docs/PROTOCOL.md: JSON lines on a stream socket for control, and fixed-header datagrams for process data.
  • The runtime side (etherdog_link.c) is written against that document and doesn't include any EtherDOG header. Constants are redefined on purpose rather than imported.
  • EtherDOG knows nothing about OpenPLC. Any program that speaks the protocol can drive it.

SO_PEERCRED: done in EtherDOG 462122a.

  • The control socket is now AF_UNIX on Linux and on Windows (MSYS2).
  • Every connection is checked by peer uid: EtherDOG's own user, root, or --allow-uid values. Anything else gets permission denied.
  • TCP is still available but refuses to start without a token, which comes from stdin or the environment, never from argv.
  • The runtime no longer uses a token (c388cde).

thiagoralves and others added 6 commits September 24, 2026 16:19
A quote in a message made the line invalid JSON, so the webserver showed it
raw at INFO; a message longer than the buffer moved the write past its end.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8oSX7QRK2xfkLVepyMs58
Every Windows adapter is \\Device\\NPF_{GUID}, so the Linux-only 15-char check
refused all EtherCAT scans and tests on Windows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8oSX7QRK2xfkLVepyMs58
The start scripts ran Python as a child, so docker stop and a closed console
never reached it and EtherDOG was killed without zeroing the outputs. exec the
webserver and give SIGTERM/SIGHUP the Ctrl+C shutdown path.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8oSX7QRK2xfkLVepyMs58
…e in the driver README

The S7comm plugin links Snap7 (LGPLv3+), so its sources take the same license
instead of MIT. Review follow-up from #203.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8oSX7QRK2xfkLVepyMs58
The EtherCAT relay journals every mapped input each bus cycle, so 2048 inputs
per master needed room beside the other plugins. Drain cost follows the writes
made, not the capacity (measured: same per-entry cost at 4096 and 40960); the
journal grows from 139 KB to 1.4 MB.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8oSX7QRK2xfkLVepyMs58
…sleep

On the SLM-RP4 plc_main needs a little over a second to open its command
socket, so every start logged connection errors before the monitor connected.
Retry for up to 10 s and report only if it never comes up.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8oSX7QRK2xfkLVepyMs58

@thiagoralves thiagoralves left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Cross-repo review of the EtherCAT split (editor #1141, web #783, runtime #205, EtherDOG #1). Two findings here, both about how the plugin and the webserver drive the bus lifecycle.

Comment thread core/src/drivers/plugins/native/ethercat/ethercat_plugin.c Outdated
Comment thread webserver/app.py
thiagoralves and others added 3 commits September 25, 2026 14:31
…t run

start_loop brought the link up once and returned -1 on failure, before the
relay thread (the only retry loop) existed; nothing called it again, so a
PLC that started before EtherDOG listened kept EtherCAT off until a manual
restart. The relay now does the first link_up too. A mapped master that
EtherDOG reports as not operational is left unbound with a warning instead
of failing every master; the bind fails only when none is operational.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8oSX7QRK2xfkLVepyMs58
The Editor stops the PLC before uploading, but other clients may not. The
old program kept running through the compile, and its EtherCAT relay
restarted the bus with the newly staged configuration. The upload now
stops the PLC first and is cancelled if it does not stop.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8oSX7QRK2xfkLVepyMs58
The app's module-level runtime manager retries plc_main in the background,
and its connect errors landed in these tests' caplog depending on timing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8oSX7QRK2xfkLVepyMs58

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

Review of the current head (d9b9840). Inline comments below; one more:

[nit] Commits 92c8767 and 2318921 carry Refs: RTOP-296 in the message body. Commit messages should describe the change without the ticket key; the PR title already links it.

Previous review: most items are resolved (reply buffer, byte-index truncation, duplicate masters, unmapped master, relay priority, first-link retry, upload ordering, stderr level, minimum versions in the message, O_NOFOLLOW, shutdown). Still open: the ETHERDOG_REF pin, and the input-frame sequence/draining, both commented inline.

Comment thread core/src/drivers/plugins/native/ethercat/ethercat_plugin.c
Comment thread core/src/drivers/plugins/native/ethercat/ethercat_iomap.c Outdated
Comment thread core/src/drivers/plugins/native/ethercat/ethercat_plugin.c
Comment thread webserver/discovery/discovery_routes.py
Comment thread install.sh
Comment thread core/src/drivers/plugins/native/ethercat/ethercat_plugin.c Outdated
Comment thread core/src/drivers/plugins/native/ethercat/ethercat_plugin.c Outdated
Comment thread webserver/etherdog_manager.py
Comment thread webserver/etherdog_manager.py
Comment thread core/src/drivers/plugins/native/ethercat/ethercat_plugin.c Outdated
Plugin:
- reject a layout whose process image exceeds one data frame (4096 bytes)
  or has no size; clamp output collection to the frame buffer
- reject a process data entry or an IEC location mapped twice, and a
  layout that lists one entry in several PDOs
- a mapping that cannot bind stops the bus and requests a PLC stop instead
  of retrying every second
- time silence per mapped master; malformed datagrams and unmapped masters
  no longer affect it
- cap the relay priority at 98 and run link setup at SCHED_OTHER
- log pthread errors from their return codes

Webserver:
- EtherDOG errors reach clients as fixed messages per failure kind; the
  detail is logged
- one upload at a time
- start() does not start a second supervisor; stop() during the leftover
  cleanup prevents a later spawn

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@thiagoralves

Copy link
Copy Markdown
Contributor Author

Review of d9b9840, addressed in b032da2. Every inline thread is answered and resolved.

Fixed:

  • the image size bound;
  • duplicate keys and locations;
  • configuration errors stopping the bus and the PLC instead of retrying;
  • error text returned to clients;
  • the upload lock;
  • relay priority and scheduling;
  • per-master silence;
  • supervisor races;
  • pthread error reporting.

Dismissed:

  • the ETHERDOG_REF pin (EtherDOG main is the stable production version);
  • frame sequence and draining;
  • the legacy-file bypasses;
  • the run directory override.

Jira key in the commit bodies (92c8767, 2318921): dismissed. Rewording would mean rewriting history on the reviewed, pushed branch and force-pushing. The new commit carries no key, and a squash merge drops it.

Tests:

  • C: the plugin suites build in Linux with AddressSanitizer and UBSan, and 58 tests pass, 11 of them new. Against the old code, the new image-size test reproduces the stack-buffer-overflow, and the bind-failure test hangs on the retry loop.
  • Python: the discovery and upload suites pass with 8 new tests. The exception is test_leftover_etherdog_is_killed, which fails on macOS on d9b9840 too because it needs /proc.

The task policy merged from development moves the dispatcher to 98 and the
watchdog to 99, so the relay is capped at 97.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@thiagoralves
thiagoralves merged commit c3bd64b into development Oct 6, 2026
3 checks passed
@thiagoralves
thiagoralves deleted the RTOP-296-etherdog-split branch October 7, 2026 17:23
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.

3 participants