Skip to content

task(DOPE-584): Python function blocks reach class parity [phase 5] - #1041

Merged
thiagoralves merged 1 commit into
task/DOPE-584-native-block-iec-parityfrom
task/DOPE-584-p5-python-classes
Aug 26, 2026
Merged

task(DOPE-584): Python function blocks reach class parity [phase 5]#1041
thiagoralves merged 1 commit into
task/DOPE-584-native-block-iec-parityfrom
task/DOPE-584-p5-python-classes

Conversation

@thiagoralves

Copy link
Copy Markdown
Contributor

What this is

Phase 5 of DOPE-584: a Python function block now sees the same Variables Table an ST block does — the same gap phase 4 closed for C++, across a harder boundary.

Before this, a Python block could see its inputs and its outputs. A VAR, a VAR_IN_OUT or a VAR_EXTERNAL declared in the same table did not cross, so the block's code referred to a name the editor had accepted and Python raised NameError.

How each class travels

A Python block is a separate process, so nothing is passed by pointer — every variable is marshalled through two packed structs. The direction each class travels follows from what it means:

Class In Out Why
input the caller supplies it
output the block produces it
inOut what VAR_IN_OUT is
local the PLC owns the storage — round-tripping is what makes a VAR the block's own state, keeps it debuggable, and lets it be retained
external same, read and written through the global's own lock
temp refused

VAR_TEMP means storage that does not survive the invocation, and Python has no such thing here: the block's variables are module globals in a process that outlives every scan. Marshalling it would not make it temporary, only a VAR wearing the wrong name. The refusal names the variable and says to declare it under VAR instead.

One selection rule

Four emitters had been filtering by class independently — the two structs, the copy loops, the format strings, and the LSP preamble. That is worse here than it was for C++: a field one side omits does not go missing, it shifts every later field's offset, so the corruption surfaces on unrelated variables. The rule now lives in block-interface and all four read it.

Widening to VAR proved immediately why the exclusion list matters. first_run, shm_in_ptr and shm_out_ptr are injected as locals, and the first hardware run swept them into the structs and handed the block its own mapped segment addresses. Python died every cycle and the loader respawned it dozens of times. They are named and excluded now, as hasBeenInitialized is on the C++ side.

Hardware verification

On slm-rp4, one block using all five classes. Over 195 Python cycles:

  • the VAR accumulated to exactly 195 x 2
  • the VAR_IN_OUT reached 195 and round-tripped to the calling program
  • the VAR_EXTERNAL reached 195 x 2, with the configuration global agreeing

Sampled three times; every counter stayed exact. A VAR_TEMP is refused at compile time. The simulator path still stubs Python out and builds at 6% flash / 3% RAM.

Coverage gaps closed on the way

  • Phase 2 added the WSTRING pack and unpack paths without tests, dropping injectPythonRuntime from 100%. Both directions are pinned now, including the truncation that must land on a code-unit boundary.
  • generatePythonLspPreamble had never covered STRING or an array of an unmappable element type, and the list[ branch in its literal helper was dead code its own caller already owned. It is gone. This one predates the feature branch — src/frontend/utils/ was already below its 100% threshold on development.

Not in scope here

🤖 Generated with Claude Code

https://claude.ai/code/session_01UaSZK4LqFWtZpERcqnZ8uQ

Same gap the C++ side had, with a harder boundary. A Python block could see its
inputs and its outputs; a VAR, a VAR_IN_OUT or a VAR_EXTERNAL declared in the
same table simply did not cross, so the block's code referred to a name the
editor had accepted and Python raised NameError at runtime.

A Python block is a separate process, so nothing can be passed by pointer.
Every variable is marshalled: the stub packs one struct on the way in, unpacks
another on the way out, and the driver decodes and re-encodes them by `struct`
format string. The direction each class travels now follows from what it means.
An input goes in, an output comes back, and VAR_IN_OUT travels both ways because
that is what it is. A VAR and a VAR_EXTERNAL also travel both ways, for a
different reason: the PLC owns the storage, so round-tripping is what makes a
VAR the block's own state, keeps it visible to the debugger, and lets it be
retained once NODE-94 lands. A block that never assigns one sends back what it
received.

VAR_TEMP is refused rather than approximated. It means storage that does not
survive the invocation, and Python has no such thing here — the block's
variables are module globals in a process that outlives every scan. Marshalling
it would not make it temporary, only a VAR wearing the wrong name. The refusal
names the variable and says to declare it under VAR instead.

An external needed care on the C side. It is a `GlobalVar<V>*` — the value plus
that global's mutex — so naming it in a copy statement would compile, convert
the pointer, and read the wrong memory holding no lock. Each one's copy is now
wrapped in `with_lock`, one at a time rather than nested: this stub only moves
values, it runs no user code, so a single lock at a time is enough and there is
no ordering to reason about. The lambda parameter is deduced, so nothing here
names `V`.

Four emitters had been filtering by class independently — the two structs, the
copy loops, the format strings, and the LSP preamble. That is the same shape of
bug the C++ side had, and worse here: a field one side omits does not go
missing, it shifts every later field's offset, so the corruption lands on
unrelated variables. One selection rule now, in `block-interface`.

Widening to VAR immediately proved why the exclusion list matters. `first_run`,
`shm_in_ptr` and `shm_out_ptr` are injected as locals, and the first run on
hardware swept them into the structs and handed the block its own mapped segment
addresses — Python died on every cycle and the loader respawned it dozens of
times. They are named and excluded now, as `hasBeenInitialized` is for C++.

Two coverage gaps closed on the way. P2 added the WSTRING pack and unpack paths
without tests, dropping `injectPythonRuntime` from 100%; they are pinned now, in
both directions, including the truncation that must land on a code-unit boundary.
And `generatePythonLspPreamble` had never covered STRING or an array of an
unmappable element type — the `list[` branch in its literal helper was dead code
its own caller already owned, and is gone.

Verified on slm-rp4: over 195 Python cycles the VAR accumulated to exactly
195 x 2, the VAR_IN_OUT reached 195 and round-tripped to the calling program,
and the VAR_EXTERNAL reached 195 x 2 with the configuration global agreeing.
Sampled three times; every counter stayed exact. A VAR_TEMP is refused at
compile time with the message above.

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

coderabbitai Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (1)
  • development

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 80f30eaf-0160-40dc-a2dc-db4bdf58ecaa

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@thiagoralves
thiagoralves merged commit bc2c8f4 into task/DOPE-584-native-block-iec-parity Aug 26, 2026
1 check was pending
@thiagoralves
thiagoralves deleted the task/DOPE-584-p5-python-classes branch August 26, 2026 04:37
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.

1 participant