Stabilize Jupyter terminal wrapper execution

This commit is contained in:
wuyang6
2026-06-15 17:14:54 +08:00
parent c795ec1b5d
commit 1d38753d08
2 changed files with 65 additions and 6 deletions
+42 -6
View File
@@ -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:
+23
View File
@@ -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()