Conversation
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
left a comment
There was a problem hiding this comment.
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:
-
The PR body describes packing the level into bits 24-30 of
packed_flagsand building frames out ofsub_context[], but the code addsGM_FIELD_CALL_STACK_IDand a persistent node ring. Which is current? Worth syncing the body with the implementation. -
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?
| cms->update((int *) &execState, 1); | ||
| cms->update((int *) &interpState, 1); | ||
| cms->update(callLevel); | ||
| for (int i = 0; i < EMC_MAX_CALL_STACK; i++) { |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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?
|
|
||
| print("{:6d} {}".format(nsamples, sample)) | ||
|
|
||
| # iscircle must agree with the motion type. This is a direct regression |
There was a problem hiding this comment.
This looks like it guards the old packed_flags approach, which this revision no longer uses. Still needed?
Gmoccapy, gladevcp and qtvcp use hal_glib to read the linuxcnc status. |
He demonstrated a Lathe-specific UI at the Stuttgart meetup which uses this. (Rather neatly) |
|
There seems to de a discrepancy between the max subroutine depth in the interpreter and the max. allowed call stack. |
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.
Add EmcCallFrame {filename, subname, line} and a callStack[] of up to EMC_MAX_CALL_STACK frames to EMC_TASK_STAT. Frame[i] records "at line L of file F we called subroutine S": filename and line come from sub_context[i] (the return address saved when entering level i+1), subname comes from sub_context[i+1].subName. At callLevel 0 the stack is empty.
InterpBase gains call_frame_filename/subname/line(level); Interp reads them out of _setup.sub_context, Canterp stubs them out.
emcTaskUpdate() refills the stack every cycle and derives callLevel from the StateTag of the move motion is currently executing, rather than from the interp_list dequeue-time value: STRAIGHT_FEED batches motion commands through the segment buffer, so all commands may be dequeued before motion starts and the dequeue-time level is unreliable. emccanon.cc packs the level into bits 24-30 of the tag's packed_flags.
Exposed to Python as stat.call_stack, a tuple of dicts with 'filename', 'subname' and 'line' keys.