Skip to content

Expose the interpreter subroutine call stack in task status - #4517

Merged
grandixximo merged 2 commits into
LinuxCNC:masterfrom
85vmh:call_stack
Sep 19, 2026
Merged

grandixximo merged 2 commits into
LinuxCNC:masterfrom
85vmh:call_stack

Conversation

@85vmh

@85vmh 85vmh commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Why

Task status carried only callLevel, so a GUI could tell that a subroutine was running
but not which one, nor where it was called from.

Worse, callLevel was sampled from the live interpreter at interp_list dequeue time,
so it described wherever the interpreter had read ahead to rather than the move the
machine was actually executing. The two are decoupled by up to [TASK]INTERP_MAX_LEN
queued canon commands plus the motion queue, so on a short program the interpreter is
typically at EOF long before motion finishes inside a sub.

How

Rather than reading the interpreter's current state, recover the stack that was active
when the executing move was interpreted:

  • The interpreter records every subroutine call as a call_stack_node holding the call
    site (filename, line), the subroutine name, and the id of its caller — a linked
    chain, so a whole stack is addressable by one integer. Nodes live in a fixed ring of
    INTERP_CALL_STACK_NODES (16384, ~512 kB of non-realtime memory) and each stores its
    own id, so a node whose slot has since been reused is detected on lookup instead of
    being reported as some unrelated call. enter_context() pushes, leave_context()
    pops, unwind_call() resets to the root.
  • write_state_tag() stamps the current node id into every block's StateTag as
    GM_FIELD_CALL_STACK_ID. It rides through segment merging and TP blending like the
    other tag fields.
  • InterpBase gains resolve_call_stack_depth(node_id) and
    resolve_call_stack_frame(node_id, level, ...), with defaults that report an empty
    stack; Interp implements them by walking the node chain. A chain that cannot be
    resolved in full reports depth 0 rather than a partial stack against a truncated
    depth.
  • emcTaskUpdate() resolves the tag of the move motion is executing and fills
    EMC_TASK_STAT::callStack[], deriving callLevel from the same id so depth and frames
    always describe one point in the program. The stack is cleared once the interpreter
    goes idle. emcTaskExecute() no longer writes callLevel.

EMC_TASK_STAT grows EmcCallFrame {filename, subname, line} and callStack[] of up to
EMC_MAX_CALL_STACK frames. Frame i reads "at line L of file F we called subroutine
S", with i == 0 being the call made from the main program. Only the frames in use are
serialised, so a program running in the main file costs nothing on the wire.

Exposed to Python as stat.call_stack, a tuple of dicts with filename, subname and
line keys.

NML buffer size

The growth pushes EMC_STAT past the 10240-byte emcStatus buffer, which would make
NML::write() silently drop status and leave LinuxCNC looking hung to every GUI, with
nothing failing at build time. configs/common/{client,server}.nml go to 20480, and
emcops.cc gains a static_assert on sizeof(EMC_STAT) so the build trips before that
can happen again.
A second static_assert in emctask.cc pins EMC_MAX_CALL_STACK to
INTERP_SUB_ROUTINE_LEVELS, so the status array and the interpreter's nesting limit
cannot drift apart.

Drive-by fix: out-of-bounds in enter_context()

Called out separately because it is a pre-existing bug, not part of the feature, and
worth applying on its own merit:

enter_context() incremented call_level before testing it against
INTERP_SUB_ROUTINE_LEVELS, so on the error path it was left one past the end of
sub_context[] — which unwind_call() then indexed. The test now happens before the
increment.

Tests

tests/interp/call-stack runs a program whose subroutines live in separate files and
checks that stat.call_stack still names outer/inner while the machine is cutting
inside them — the point at which the live interpreter has already read to EOF, and
exactly the case the old callLevel got wrong.

tests/interp and tests/motion pass in full (89 tests, 1 skipped —
tests/interp/compile, already disabled on master).

Note on an earlier revision

An earlier version of this PR packed the call level into bits 24–30 of the StateTag's
packed_flags and rebuilt frames from sub_context[]. That approach desynchronised the
displayed stack from the executing move once read-ahead got more than one call deep, and
it was replaced at the Stuttgart meetup by the node-id design described above. This
description previously still described that first version; it has been rewritten to match
the code. Thanks to @rmu75 for spotting the mismatch.

Task status carried only callLevel, so a GUI could tell that a subroutine was
running but not which one, nor where it was called from.  Worse, callLevel was
sampled from the live interpreter at interp_list dequeue time, so it described
wherever the interpreter had read ahead to rather than the move the machine was
actually executing -- the two are decoupled by up to [TASK]INTERP_MAX_LEN queued
canon commands plus the motion queue.

Rather than reading the interpreter's current state, recover the stack that was
active when the executing move was interpreted:

- The interpreter records every subroutine call as a call_stack_node holding the
  call site (filename, line), the subroutine name, and the id of its caller --
  a linked chain, so a whole stack is addressable by one integer.  Nodes live in
  a fixed ring of INTERP_CALL_STACK_NODES (16384, ~512kB of non-realtime memory)
  and each stores its own id, so a node whose slot has been reused is detected on
  lookup instead of being reported as some unrelated call.  enter_context()
  pushes, leave_context() pops, unwind_call() resets to the root.
- write_state_tag() stamps the current node id into every block's StateTag as
  GM_FIELD_CALL_STACK_ID.  It rides through segment merging and TP blending like
  the other tag fields.
- InterpBase gains resolve_call_stack_depth(node_id) and
  resolve_call_stack_frame(node_id, level, ...), with defaults that report an
  empty stack; Interp implements them by walking the node chain.  A chain that
  cannot be resolved in full reports depth 0 rather than a partial stack against
  a truncated depth.
- emcTaskUpdate() resolves the tag of the move motion is executing and fills
  EMC_TASK_STAT::callStack[], deriving callLevel from the same id so depth and
  frames always describe one point in the program.  The stack is cleared once
  the interpreter goes idle.  emcTaskExecute() no longer writes callLevel.

EMC_TASK_STAT grows EmcCallFrame {filename, subname, line} and callStack[] of up
to EMC_MAX_CALL_STACK frames.  Frame[i] reads "at line L of file F we called
subroutine S", i == 0 being the call made from the main program.

That growth pushes EMC_STAT past the 10240-byte emcStatus NML buffer, which
would make NML::write() silently drop status and leave LinuxCNC looking hung to
every GUI, with nothing failing at build time.  configs/common/{client,server}.nml
go to 20480, and emcops.cc gains a static_assert on sizeof(EMC_STAT) so the build
trips before that can happen again.

Exposed to Python as stat.call_stack, a tuple of dicts with 'filename',
'subname' and 'line' keys.

tests/interp/call-stack runs a program whose subroutines sit in separate files
and checks that stat.call_stack still names outer/inner while the machine is
cutting inside them -- the point at which the live interpreter has already read
to EOF, and the case the old callLevel got wrong.

Two bounds fixes found along the way, both reachable before this change:
enter_context() incremented call_level before testing it against
INTERP_SUB_ROUTINE_LEVELS, leaving it one past the end of sub_context[] on the
error path, which unwind_call() then indexed; and interpmodule's set_call_level
setter accepted any int from Python into the same index.

tests/motion/heading gains an assertion that iscircle agrees with the motion
type, as a regression guard on the StateTag flag bits.

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

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

Thanks, this is a clean design. Resolving the stack from the executing move's StateTag instead of the live interpreter is the right call, since the interpreter is typically at EOF long before motion finishes inside a sub. The aged-id detection in the ring and the all-or-nothing fallback on a failed mid-walk lookup are nice touches.

Two questions:

  1. The PR body describes packing the level into bits 24-30 of packed_flags and building frames out of sub_context[], but the code adds GM_FIELD_CALL_STACK_ID and a persistent node ring. Which is current? Worth syncing the body with the implementation.

  2. Nothing in-tree consumes this yet (axis, gmoccapy, qtvcp don't read even the existing call_level). Do you have a UI follow-up in the pipeline, or is this landing as infrastructure for others to build on?

Comment thread src/emc/nml_intf/emc.cc Outdated
cms->update((int *) &execState, 1);
cms->update((int *) &interpState, 1);
cms->update(callLevel);
for (int i = 0; i < EMC_MAX_CALL_STACK; i++) {

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.

callLevel is serialized just above, so this loop could run to callLevel instead of EMC_MAX_CALL_STACK. On local shmem it's noise, but remote NML clients pay for 10 dead frames per cycle whenever the program is in main.

if (settings->call_level >= INTERP_SUB_ROUTINE_LEVELS) {
// check before incrementing: leaving call_level past the end of
// sub_context[] would make unwind_call() index out of bounds
if (settings->call_level + 1 >= INTERP_SUB_ROUTINE_LEVELS) {

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.

This fixes a real pre-existing out-of-bounds on sub_context[] (old code incremented past the end before erroring). Good catch. Could you mention it in the PR body so it doesn't hide inside the feature?

Comment thread tests/motion/heading/test-ui.py Outdated

print("{:6d} {}".format(nsamples, sample))

# iscircle must agree with the motion type. This is a direct regression

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.

This looks like it guards the old packed_flags approach, which this revision no longer uses. Still needed?

@c-morley

c-morley commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator
  1. Nothing in-tree consumes this yet (axis, gmoccapy, qtvcp don't read even the existing call_level). Do you have a UI follow-up in the pipeline, or is this landing as infrastructure for others to build on?

Gmoccapy, gladevcp and qtvcp use hal_glib to read the linuxcnc status.
Call_level is used there to stop file reloading during subroutines/remaps.
Seeing subroutine code is probably not bad but seeing remap code is bad.
Maybe with this new information we can control that better.

@andypugh

Copy link
Copy Markdown
Collaborator

Nothing in-tree consumes this yet (axis, gmoccapy, qtvcp don't read even the existing call_level). Do you have a UI follow-up in the pipeline

He demonstrated a Lathe-specific UI at the Stuttgart meetup which uses this. (Rather neatly)

@BsAtHome

Copy link
Copy Markdown
Contributor

There seems to de a discrepancy between the max subroutine depth in the interpreter and the max. allowed call stack.
What about synch'ing the define INTERP_SUB_ROUTINE_LEVELS?

@rmu75

rmu75 commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

The original version didn't use state tags, so with read ahead and more than one subroutine call you would get desychronised call stacks in the GUI. We fixed that at the Stuttgart meeting.

IMO not showing the file that is currently executing is an anti-feature, I definitely want to see code of macros and even remaps, esp. in case of probing or tool changing routines and when single-stepping.

@rmu75

rmu75 commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

We had some discussion how big to make the ring buffer. As the buffer is not living in shared memory (the state tag merely contains an opaque ID), it could be further refined to use more dynamic structures and keep in principle "infinite" call stacks. The structure in EMCSTAT should probably be increased to accomodate INTERP_SUB_ROUTINE_LEVELS frames.

@rmu75

rmu75 commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

The code was generated by Claude IIRC, and I think 85mvh is not comfortable to write c++, so we will either have to fork this PR or get his Claude to impl the review changes.

@BsAtHome

Copy link
Copy Markdown
Contributor

The code was generated by Claude IIRC, and I think 85mvh is not comfortable to write c++, so we will either have to fork this PR or get his Claude to impl the review changes.

A case of "I fixed it but I don't know what I did or how I did it" is a very, very bad sign.

@rmu75

rmu75 commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

The code was generated by Claude IIRC, and I think 85mvh is not comfortable to write c++, so we will either have to fork this PR or get his Claude to impl the review changes.

A case of "I fixed it but I don't know what I did or how I did it" is a very, very bad sign.

I had a look at it, I'm somewhat familiar with the changed code, nothing was obviously wrong, contrary to the original version it also worked with multiple nested calls showing correct call stacks in @85vmh 's GUI, but it was also clear that some details like max call stack depth need some more thinking.

@rmu75

rmu75 commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator
1. The PR body describes packing the level into bits 24-30 of `packed_flags` and building frames out of `sub_context[]`, but the code adds `GM_FIELD_CALL_STACK_ID` and a persistent node ring. Which is current? Worth syncing the body with the implementation.

I think the PR body pertains to the first (incorrect) version, that needs updating.

@85vmh

85vmh commented Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

Hi guys,
Thanks for taking the time to review and discuss about this PR.
I am not a C/C++ developer. My background is in object oriented languages like C#, Java, Kotlin, the latest 16 years being an Android Developer.
I have a Weiler E30 teachin lathe for which I wanted to write my own custom controller.
What I presented at Stuttgart is the Python + QML version of my controlller (After I finish the prototyping phase, the plan is to rewrite everything in Kotlin Compose multiplatform).

I've built a conversational system that for some functions is relying on calling subroutines that can call other subroutines. With the current implementation, I could not represent that on the UI.
Here is a custom program whose purpose is to show how the UI will render the call stack of a program in my custom gcode viewer. I've done all these changes on my fork and my controller is using this fork that was about 1300 commits behind the main branch. The guys there liked my UI and they proposed that I should be integrating my changes into master as they are additions to the current functionalities that are enabling other UI's to render a more accurate representation of what's actually happening in the code.

image

I would like to have my changes integrated in master because it will be easier for me to continue my development without having to continuously rebase my fork with master. If these changes are not merged, my fork will go on in parallel with the changes that I need in order to obtain the features that I want in my app.
I agree with all those comments that say that its a "I fixed it but I don't know what I did or how I did it" style, and its true.
Even if I don't write c/c++ I kinda understand what's happening, but I don't know yet all the internal architecture of linuxcnc to audit what claude is doing. For that purpose I need you guys! :)

Review fixes.

EMC_TASK_STAT::update() sent all EMC_MAX_CALL_STACK frames on every cycle, so an
NML client received ten empty frames whenever the program was running in the
main file.  It now sends callLevel frames.  callLevel itself is sent just above
the loop, so the reader already knows how many frames follow it.

callLevel is also limited to the length of callStack[] before the loop uses it.
When reading, it has just been taken out of the message and the loop uses it to
index the array, so a damaged message would otherwise read past the end.

Since the entries above callLevel are no longer sent, a GUI keeps whatever it
last read in them.  The comment on callStack[] now says that only the first
callLevel entries are valid; the Python binding already returns only that many.
emcTaskUpdate() still clears the rest, which now affects only task's own copy of
the status, and the comment there says so.

The comment above EMC_MAX_CALL_STACK said that its relation to
INTERP_SUB_ROUTINE_LEVELS was "asserted in emctask.cc", and there was no such
assertion.  Add it to emctask.cc, the only file that includes both headers.
Both constants are 10, so nothing changes size: the point is that they can no
longer be changed apart from each other without the build failing.

Remove the iscircle check added to tests/motion/heading/test-ui.py.  It tested
the StateTag flag bits that an earlier version of this work used, and the
current one carries a node id in GM_FIELD_CALL_STACK_ID instead, so the check no
longer covers anything this branch does.  tests/motion/heading is now untouched
by this branch.

Raised in review by grandixximo (the frames sent and the heading test) and by
BsAtHome and rmu75 (the array size).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@85vmh
85vmh requested a review from grandixximo September 18, 2026 20:15
@grandixximo
grandixximo merged commit 972a90b into LinuxCNC:master Sep 19, 2026
17 checks passed
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.

6 participants