Stabilize Jupyter terminal wrapper execution
This commit is contained in:
+42
-6
@@ -503,6 +503,9 @@ PY
|
|||||||
)
|
)
|
||||||
|
|
||||||
def put_file_bytes(self, api_path: str, data: bytes) -> None:
|
def put_file_bytes(self, api_path: str, data: bytes) -> None:
|
||||||
|
parent_api = '/'.join(api_path.split('/')[:-1])
|
||||||
|
if parent_api:
|
||||||
|
self._ensure_remote_dir(parent_api)
|
||||||
encoded = base64.b64encode(data).decode('ascii')
|
encoded = base64.b64encode(data).decode('ascii')
|
||||||
url = f'{self.binding.base_url}/api/contents/{quote(api_path, safe="/")}'
|
url = f'{self.binding.base_url}/api/contents/{quote(api_path, safe="/")}'
|
||||||
response = self._session.put(
|
response = self._session.put(
|
||||||
@@ -538,6 +541,8 @@ PY
|
|||||||
start_marker = f'__ZK_AGENT_START_{uuid.uuid4().hex}__'
|
start_marker = f'__ZK_AGENT_START_{uuid.uuid4().hex}__'
|
||||||
workspace = shlex.quote(self.binding.workspace_cwd)
|
workspace = shlex.quote(self.binding.workspace_cwd)
|
||||||
run_dir = f'{self.binding.workspace_cwd}/scratchpad/.run_pids'
|
run_dir = f'{self.binding.workspace_cwd}/scratchpad/.run_pids'
|
||||||
|
script_dir = self._terminal_script_dir()
|
||||||
|
script_path = f'{script_dir}/{marker}.sh'
|
||||||
quoted_run_dir = shlex.quote(run_dir)
|
quoted_run_dir = shlex.quote(run_dir)
|
||||||
pid_file = shlex.quote(f'{run_dir}/{marker}.pid')
|
pid_file = shlex.quote(f'{run_dir}/{marker}.pid')
|
||||||
env_prefix = self._remote_env_prefix()
|
env_prefix = self._remote_env_prefix()
|
||||||
@@ -561,11 +566,20 @@ PY
|
|||||||
f'rm -f {pid_file}; '
|
f'rm -f {pid_file}; '
|
||||||
f'printf "\\n{marker}:%s\\n" "$__zk_status"'
|
f'printf "\\n{marker}:%s\\n" "$__zk_status"'
|
||||||
)
|
)
|
||||||
# Run the wrapper inside a foreground non-interactive bash. Sending the
|
# Keep the terminal input simple. Sending a deeply nested one-line
|
||||||
# whole background-job wrapper directly to the terminal shell can make
|
# script directly to the interactive shell is fragile: complex
|
||||||
# the interactive shell print job-control lines such as
|
# if/then/fi command strings can trigger continuation prompts, and
|
||||||
# "[1] + Done (setsid bash -lc ...)", which can hide the real stdout.
|
# background jobs can print "[1] + Done ..." job-control noise. Upload
|
||||||
wrapped = f'bash -lc {shlex.quote(wrapper_script)}'
|
# the wrapper through the Jupyter Contents API, then execute that temp
|
||||||
|
# script in the foreground.
|
||||||
|
self.put_file_bytes(
|
||||||
|
api_path_for_absolute_path(script_path),
|
||||||
|
wrapper_script.encode('utf-8'),
|
||||||
|
)
|
||||||
|
wrapped = build_terminal_script_launcher(
|
||||||
|
script_path=script_path,
|
||||||
|
marker=marker,
|
||||||
|
)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
name = self._ensure_terminal()
|
name = self._ensure_terminal()
|
||||||
ws_url = self._terminal_websocket_url(name)
|
ws_url = self._terminal_websocket_url(name)
|
||||||
@@ -599,6 +613,12 @@ PY
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def _terminal_script_dir(self) -> str:
|
||||||
|
session_part = sanitize_remote_path_part(self.binding.session_id)
|
||||||
|
# Jupyter Contents API usually rejects hidden path components, so this
|
||||||
|
# directory must not start with a dot.
|
||||||
|
return f'{self.binding.workspace_root}/run_scripts/{session_part}'
|
||||||
|
|
||||||
def run_python(
|
def run_python(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -1295,7 +1315,7 @@ def clean_terminal_output(text: str) -> str:
|
|||||||
for line in cleaned.splitlines()
|
for line in cleaned.splitlines()
|
||||||
if (
|
if (
|
||||||
line.strip() not in {'#', '>'}
|
line.strip() not in {'#', '>'}
|
||||||
and not re.fullmatch(r'(>\s*)+', line.strip())
|
and not re.fullmatch(r'(#\s*)?(>\s*)+', line.strip())
|
||||||
and not is_wrapper_job_control_line(line.strip())
|
and not is_wrapper_job_control_line(line.strip())
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
@@ -1314,6 +1334,22 @@ def is_wrapper_job_control_line(line: str) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_terminal_script_launcher(
|
||||||
|
script_path: str,
|
||||||
|
*,
|
||||||
|
marker: str,
|
||||||
|
) -> str:
|
||||||
|
quoted_path = shlex.quote(script_path)
|
||||||
|
return (
|
||||||
|
f'bash {quoted_path}; '
|
||||||
|
'__zk_outer_status=$?; '
|
||||||
|
f'rm -f {quoted_path}; '
|
||||||
|
'if [ "$__zk_outer_status" -ne 0 ]; then '
|
||||||
|
f'printf "\\n{marker}:%s\\n" "$__zk_outer_status"; '
|
||||||
|
'fi'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def extract_terminal_command_output(text: str, *, start_marker: str) -> str:
|
def extract_terminal_command_output(text: str, *, start_marker: str) -> str:
|
||||||
"""Drop terminal echo/prompt text before the wrapper's real start marker."""
|
"""Drop terminal echo/prompt text before the wrapper's real start marker."""
|
||||||
if not start_marker:
|
if not start_marker:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from src.jupyter_runtime import (
|
from src.jupyter_runtime import (
|
||||||
|
build_terminal_script_launcher,
|
||||||
clean_terminal_output,
|
clean_terminal_output,
|
||||||
extract_terminal_command_output,
|
extract_terminal_command_output,
|
||||||
)
|
)
|
||||||
@@ -43,6 +44,28 @@ class TestJupyterRuntimeOutputFiltering(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(cleaned, 'real-output')
|
self.assertEqual(cleaned, 'real-output')
|
||||||
|
|
||||||
|
def test_clean_terminal_output_drops_continuation_prompt(self) -> None:
|
||||||
|
cleaned = clean_terminal_output('real-output\r\n# >\r\n')
|
||||||
|
|
||||||
|
self.assertEqual(cleaned, 'real-output')
|
||||||
|
|
||||||
|
def test_terminal_script_launcher_hides_complex_wrapper_from_shell(self) -> None:
|
||||||
|
launcher = build_terminal_script_launcher(
|
||||||
|
script_path='/workspace/scratchpad/.run_scripts/run.sh',
|
||||||
|
marker='__ZK_AGENT_EXIT_test__',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertNotIn('setsid bash -lc', launcher)
|
||||||
|
self.assertEqual(
|
||||||
|
launcher,
|
||||||
|
'bash /workspace/scratchpad/.run_scripts/run.sh; '
|
||||||
|
'__zk_outer_status=$?; '
|
||||||
|
'rm -f /workspace/scratchpad/.run_scripts/run.sh; '
|
||||||
|
'if [ "$__zk_outer_status" -ne 0 ]; then '
|
||||||
|
'printf "\\n__ZK_AGENT_EXIT_test__:%s\\n" "$__zk_outer_status"; '
|
||||||
|
'fi',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user