Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions openc3-cosmos-script-runner-api/spec/models/running_script_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,59 @@
end
end

describe "goto" do
let(:script_api) { Object.new.extend(OpenC3::Script) }

after do
RunningScript.class_variable_set(:@@run_thread, nil)
end

it "hands off to the run thread when called from another thread" do
# The debug prompt evals goto on the redis subscriber thread. Running the
# script there deadlocks at the next pause because that thread is the one
# that processes go/step/stop, so goto must defer to the run thread.
running_script = RunningScript.new(script_status)
script_status.state = "breakpoint"
RunningScript.class_variable_set(:@@run_thread, Thread.new {})
expect(script_api).not_to receive(:start)

script_api.send(:goto, 9)
expect(running_script.execute_while_paused_info).to eq({ filename: "test_script.rb", line_no: 9, end_line_no: nil, bind_variables: true })

script_api.send(:goto, "other.rb", 3)
expect(running_script.execute_while_paused_info).to eq({ filename: "other.rb", line_no: 3, end_line_no: nil, bind_variables: true })
end

it "keeps the script binding when the queued goto targets another file" do
running_script = RunningScript.new(script_status)
script_status.state = "breakpoint"
RunningScript.class_variable_set(:@@run_thread, Thread.new {})
script_api.send(:goto, "other.rb", 3)

expect(running_script).to receive(:start).with("other.rb", line_no: 3, bind_variables: true, complete: true)
running_script.check_execute_while_paused
end

it "binds the editor's goto only when it targets the current file" do
running_script = RunningScript.new(script_status)
script_status.state = "breakpoint"
running_script.execute_while_paused("other.rb", 3)

expect(running_script).to receive(:start).with("other.rb", line_no: 3, bind_variables: false, complete: true)
running_script.check_execute_while_paused
Comment thread
jmthomas marked this conversation as resolved.
end

it "runs the script directly when called from the run thread" do
RunningScript.new(script_status)
RunningScript.class_variable_set(:@@run_thread, Thread.current)
expect(script_api).to receive(:start).with("test_script.rb", line_no: 9, bind_variables: true, complete: true)
script_api.send(:goto, 9)

expect(script_api).to receive(:start).with("other.rb", line_no: 3, bind_variables: true, complete: true)
script_api.send(:goto, "other.rb", 3)
end
end

describe "breakpoint management" do
it "sets a breakpoint" do
RunningScript.set_breakpoint("test.rb", 10)
Expand Down
29 changes: 21 additions & 8 deletions openc3/lib/openc3/utilities/running_script.rb
Original file line number Diff line number Diff line change
Expand Up @@ -275,10 +275,19 @@ def start(procedure_name, line_no: 1, end_line_no: nil, bind_variables: false, c

def goto(line_no_or_procedure_name, line_no = nil)
if line_no.nil?
start(RunningScript.instance.current_filename, line_no: line_no_or_procedure_name, bind_variables: true, complete: true)
procedure_name = RunningScript.instance.current_filename
line_no = line_no_or_procedure_name
else
start(line_no_or_procedure_name, line_no: line_no, bind_variables: true, complete: true)
procedure_name = line_no_or_procedure_name
end
# goto typed at the debug prompt is evaluated on the thread that processes
# go/step/stop messages. Running the script there deadlocks at the next pause,
# so hand it to the run thread the same way the editor's goto does.
unless RunningScript.run_thread?
RunningScript.instance.execute_while_paused(procedure_name, line_no, nil, true)
return
end
start(procedure_name, line_no: line_no, bind_variables: true, complete: true)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
end

# Require an additional ruby file
Expand Down Expand Up @@ -797,6 +806,10 @@ def self.instance=(value)
@@instance = value
end

def self.run_thread?
Thread.current == @@run_thread
end

def self.line_delay
@@line_delay
end
Expand Down Expand Up @@ -1103,9 +1116,10 @@ def current_backtrace
trace
end

def execute_while_paused(filename, line_no = 1, end_line_no = nil)
# bind_variables of nil binds only when filename is the currently running file
def execute_while_paused(filename, line_no = 1, end_line_no = nil, bind_variables = nil)
if @script_status.state == 'paused' or @script_status.state == 'error' or @script_status.state == 'breakpoint'
@execute_while_paused_info = { filename: filename, line_no: line_no, end_line_no: end_line_no }
@execute_while_paused_info = { filename: filename, line_no: line_no, end_line_no: end_line_no, bind_variables: bind_variables }
else
scriptrunner_puts("Cannot execute selection or goto unless script is paused, breakpoint, or in error state")
end
Expand Down Expand Up @@ -1217,10 +1231,9 @@ def wait_for_go_or_stop_or_retry(error = nil)

def check_execute_while_paused
if @execute_while_paused_info
if @script_status.current_filename == @execute_while_paused_info[:filename]
bind_variables = true
else
bind_variables = false
bind_variables = @execute_while_paused_info[:bind_variables]
if bind_variables.nil?
bind_variables = (@script_status.current_filename == @execute_while_paused_info[:filename])
end
if @execute_while_paused_info[:end_line_no]
# Execute Selection While Paused
Expand Down
39 changes: 22 additions & 17 deletions openc3/python/openc3/utilities/running_script.py
Original file line number Diff line number Diff line change
Expand Up @@ -702,7 +702,8 @@
trace.append(f"{filename}:{lineno}:{name}:{line}")
return trace

def execute_while_paused(self, filename, line_no=1, end_line_no=None):
# bind_variables of None binds only when filename is the currently running file
def execute_while_paused(self, filename, line_no=1, end_line_no=None, bind_variables=None):
if (
self.script_status.state == "paused"
or self.script_status.state == "error"
Expand All @@ -712,6 +713,7 @@
"filename": filename,
"line_no": line_no,
"end_line_no": end_line_no,
"bind_variables": bind_variables,
}
else:
self.scriptrunner_puts(
Expand Down Expand Up @@ -864,10 +866,10 @@
def check_execute_while_paused(self):
try:
if self.execute_while_paused_info is not None:
if self.script_status.current_filename == self.execute_while_paused_info.get("filename"):
bind_variables = True
else:
bind_variables = False
filename = self.execute_while_paused_info.get("filename")
bind_variables = self.execute_while_paused_info.get("bind_variables")
if bind_variables is None:
bind_variables = self.script_status.current_filename == filename
if self.execute_while_paused_info.get("end_line_no"):
# Execute Selection While Paused
state = self.script_status.state
Expand Down Expand Up @@ -1481,19 +1483,22 @@

def goto(line_no_or_procedure_name, line_no=None):
if line_no is None:
start(
RunningScript.instance.current_filename(),
line_no=line_no_or_procedure_name,
bind_variables=True,
complete=True,
)
procedure_name = RunningScript.instance.current_filename()
Comment thread
jmthomas marked this conversation as resolved.
Dismissed
line_no = line_no_or_procedure_name
else:
start(
line_no_or_procedure_name,
line_no=line_no,
bind_variables=True,
complete=True,
)
procedure_name = line_no_or_procedure_name
# goto typed at the debug prompt is executed on the thread that processes
# go/step/stop messages. Running the script there deadlocks at the next pause,
# so hand it to the run thread the same way the editor's goto does.
if threading.current_thread() is not RunningScript.run_thread:
RunningScript.instance.execute_while_paused(procedure_name, line_no, bind_variables=True)
return
start(
procedure_name,
line_no=line_no,
Comment thread
jmthomas marked this conversation as resolved.
Dismissed
bind_variables=True,
Comment thread
jmthomas marked this conversation as resolved.
Dismissed
complete=True,
Comment thread
jmthomas marked this conversation as resolved.
Dismissed
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


openc3.script.goto = goto
Expand Down
112 changes: 112 additions & 0 deletions openc3/python/test/utilities/test_running_script.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# Copyright 2026 OpenC3, Inc.
# All Rights Reserved.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
# See LICENSE.md for more details.

# This file may also be used under the terms of a commercial license
# if purchased from OpenC3, Inc.

import importlib
import sys
import threading
import unittest
from unittest.mock import MagicMock, patch

import openc3.script
import openc3.script.api_shared
import openc3.utilities


running_script = None
RunningScript = None


class TestGoto(unittest.TestCase):
@classmethod
def setUpClass(cls):
# Importing running_script replaces functions on openc3.script and
# openc3.script.api_shared for the whole process, which breaks other tests.
# Import it here and put those modules back when we're done.
global running_script, RunningScript
cls.saved_script = dict(vars(openc3.script))
cls.saved_api_shared = dict(vars(openc3.script.api_shared))
running_script = importlib.import_module("openc3.utilities.running_script")
RunningScript = running_script.RunningScript

@classmethod
def tearDownClass(cls):
for module, saved in (
(openc3.script, cls.saved_script),
(openc3.script.api_shared, cls.saved_api_shared),
):
vars(module).clear()
vars(module).update(saved)
sys.modules.pop("openc3.utilities.running_script", None)
if hasattr(openc3.utilities, "running_script"):
delattr(openc3.utilities, "running_script")

def setUp(self):
# Skip __init__ since it redirects stdout/stderr and talks to Redis
self.running_script = RunningScript.__new__(RunningScript)
self.running_script.script_status = MagicMock(state="breakpoint", current_filename="test_script.py")
self.running_script.execute_while_paused_info = None
RunningScript.instance = self.running_script
start_patcher = patch.object(running_script, "start")
self.mock_start = start_patcher.start()
self.addCleanup(start_patcher.stop)

def tearDown(self):
RunningScript.instance = None

def test_goto_off_run_thread_hands_off_to_run_thread(self):
mock_start = self.mock_start
# The debug prompt execs goto on the redis subscriber thread. Running the
# script there deadlocks at the next pause because that thread is the one
# that processes go/step/stop, so goto must defer to the run thread.
patcher = patch.object(RunningScript, "run_thread", threading.Thread(target=lambda: None))
patcher.start()
self.addCleanup(patcher.stop)

running_script.goto(9)
self.assertEqual(
self.running_script.execute_while_paused_info,
{"filename": "test_script.py", "line_no": 9, "end_line_no": None, "bind_variables": True},
)

running_script.goto("other.py", 3)
self.assertEqual(
self.running_script.execute_while_paused_info,
{"filename": "other.py", "line_no": 3, "end_line_no": None, "bind_variables": True},
)
mock_start.assert_not_called()

def test_queued_goto_to_another_file_keeps_binding(self):
patcher = patch.object(RunningScript, "run_thread", threading.Thread(target=lambda: None))
patcher.start()
self.addCleanup(patcher.stop)
running_script.goto("other.py", 3)

self.running_script.check_execute_while_paused()
self.mock_start.assert_called_with("other.py", line_no=3, bind_variables=True, complete=True)

def test_editor_goto_binds_only_for_current_file(self):
self.running_script.execute_while_paused("other.py", 3)

self.running_script.check_execute_while_paused()
self.mock_start.assert_called_with("other.py", line_no=3, bind_variables=False, complete=True)

def test_goto_on_run_thread_starts_directly(self):
mock_start = self.mock_start
patcher = patch.object(RunningScript, "run_thread", threading.current_thread())
patcher.start()
self.addCleanup(patcher.stop)

running_script.goto(9)
mock_start.assert_called_with("test_script.py", line_no=9, bind_variables=True, complete=True)

running_script.goto("other.py", 3)
mock_start.assert_called_with("other.py", line_no=3, bind_variables=True, complete=True)
self.assertIsNone(self.running_script.execute_while_paused_info)
Loading