From e97ddc58b1fbfad3f8f337e81f217f4f15f807f9 Mon Sep 17 00:00:00 2001 From: Sree Narayanan Date: Wed, 21 Oct 2026 09:35:27 +0400 Subject: [PATCH] fix(execution): give Python SystemExit Linux exit codes The Python runner ran every main program through pyodide.runPythonAsync. SystemExit escaped Pyodide's event loop as an uncaught JS error, so sys.exit(n) always exited 1, and every uncaught exception printed a Pyodide JS stack trace. Run main programs (-c, files, stdin, -m, REPL, pip, and retained contexts) through one Python helper that maps SystemExit to its CPython exit status and prints uncaught exceptions through sys.excepthook with the runner and Pyodide frames removed. --- .../assets/runners/python-runner.mjs | 79 +++++++++++++++---- crates/execution/src/python.rs | 2 +- crates/native-sidecar/tests/python.rs | 52 ++++++++++++ docs/content/docs/python.mdx | 5 ++ 4 files changed, 123 insertions(+), 15 deletions(-) diff --git a/crates/execution/assets/runners/python-runner.mjs b/crates/execution/assets/runners/python-runner.mjs index 2b86d9d071..a8dfe850e9 100644 --- a/crates/execution/assets/runners/python-runner.mjs +++ b/crates/execution/assets/runners/python-runner.mjs @@ -2345,17 +2345,20 @@ del _agentos_install_bundled_finder bundledFiles.has(lockfilePackages[normalizePythonPackageName(distributionName)]?.file_name); } +// Filename for runner-owned Python source, so tracebacks can drop its frames. +const PYTHON_INTERNAL_FILENAME = ''; + // `pip` / `python -m pip`: emulate the common pip CLI via Pyodide's micropip, // which fetches wheels through the runner's kernel-backed fetch (so egress is // governed by the VM network policy, never an ambient host fetch). Installed // packages are copied into the persistent VFS site-packages so they survive the // per-process interpreter and can be imported by a later `python` invocation. // Bundled packages are importable in every process, so they are not copied. -async function runPythonPip(pyodide, isBundledPackage) { +async function runPythonPip(pyodide, runMain, isBundledPackage) { pyodide.globals.set('__agentos_vfs_site', PYTHON_VFS_SITE_PACKAGES); pyodide.globals.set('__agentos_is_bundled_package', isBundledPackage); try { - await pyodide.runPythonAsync(` + return await runMain(` import importlib.metadata, os, shutil, site, sys _agentos_pip_args = sys.argv[1:] if _agentos_pip_args and _agentos_pip_args[0] == "install": @@ -2398,7 +2401,7 @@ elif _agentos_pip_args and _agentos_pip_args[0] == "list": else: print("usage: pip install [ ...]", file=sys.stderr) sys.exit(2) -`); +`, PYTHON_INTERNAL_FILENAME); } finally { try { pyodide.globals.delete('__agentos_vfs_site'); @@ -2414,8 +2417,8 @@ else: // Minimal interactive REPL backed by the kernel stdin stream (sys.stdin via // setStdin). Prompts use the standard PS1/PS2; EOF on stdin ends the session. -async function runPythonRepl(pyodide) { - await pyodide.runPythonAsync(` +function runPythonRepl(runMain) { + return runMain(` import sys from code import InteractiveConsole if not hasattr(sys, "ps1"): @@ -2423,7 +2426,50 @@ if not hasattr(sys, "ps1"): if not hasattr(sys, "ps2"): sys.ps2 = "... " InteractiveConsole(locals={"__name__": "__main__", "__doc__": None}).interact(banner="", exitmsg="") -`); +`, PYTHON_INTERNAL_FILENAME); +} + +// Returns `runMain(source, filename)`, which runs a main program in `__main__` +// and resolves to its process exit status the way CPython decides it: +// `SystemExit` maps to its code, and any other uncaught exception prints a +// Python traceback through `sys.excepthook` and exits 1. Without this, +// `SystemExit` escapes Pyodide's event loop as an uncaught JS error. +function installPythonMainRunner(pyodide) { + pyodide.runPython( + ` +async def _agentos_run_main(source, filename): + import __main__, sys + from _pyodide._base import __file__ as pyodide_base_file, eval_code_async + + try: + await eval_code_async(source, __main__.__dict__, filename=filename) + return 0 + except SystemExit as error: + code = error.code + if code is None: + return 0 + if isinstance(code, int): + return code & 0xFF + print(code, file=sys.stderr) + return 1 + except BaseException as error: + tb = error.__traceback__ + while tb is not None and tb.tb_frame.f_code.co_filename in ( + ${JSON.stringify(PYTHON_INTERNAL_FILENAME)}, + pyodide_base_file, + ): + tb = tb.tb_next + sys.excepthook(type(error), error, tb) + return 1 + finally: + sys.stdout.flush() + sys.stderr.flush() +`, + { filename: PYTHON_INTERNAL_FILENAME }, + ); + const runMain = pyodide.globals.get('_agentos_run_main'); + pyodide.globals.delete('_agentos_run_main'); + return runMain; } function resolvePythonSource(pyodide) { @@ -2512,9 +2558,6 @@ try { error, ); }); - if (retainLanguageContext) { - globalThis.__agentOSRetainedPyodide = pyodide; - } restorePyodideShellCompat(); emitWarmupStage('after-load-pyodide'); const loadPyodideMs = realPerformance.now() - loadPyodideStarted; @@ -2553,23 +2596,31 @@ try { loadPyodideMs, source, }); + const runMain = installPythonMainRunner(pyodide); + if (retainLanguageContext) { + globalThis.__agentOSRunRetainedPython = (code) => runMain(code, ''); + } if (moduleName === 'pip') { - await runPythonPip(pyodide, isBundledPackage); + process.exitCode = await runPythonPip(pyodide, runMain, isBundledPackage); } else if (moduleName) { pyodide.globals.set('__agentos_module', moduleName); try { - await pyodide.runPythonAsync( + process.exitCode = await runMain( 'import runpy\nrunpy.run_module(__agentos_module, run_name="__main__", alter_sys=True)', + PYTHON_INTERNAL_FILENAME, ); } finally { pyodide.globals.delete('__agentos_module'); } } else if (stdinProgram) { - await pyodide.runPythonAsync(readProgramFromStdin()); + process.exitCode = await runMain(readProgramFromStdin(), ''); } else if (interactive) { - await runPythonRepl(pyodide); + process.exitCode = await runPythonRepl(runMain); } else { - await pyodide.runPythonAsync(resolvePythonSource(pyodide)); + process.exitCode = await runMain( + resolvePythonSource(pyodide), + readRunnerEnv(PYTHON_FILE_ENV) ?? '', + ); } } } catch (error) { diff --git a/crates/execution/src/python.rs b/crates/execution/src/python.rs index 80fcc1f51b..af7e0b5ad1 100644 --- a/crates/execution/src/python.rs +++ b/crates/execution/src/python.rs @@ -544,7 +544,7 @@ impl PythonExecution { ))) })?; let runner = format!( - "process.exitCode = 0; (async () => {{ const pyodide = globalThis.__agentOSRetainedPyodide; if (!pyodide) throw new Error('retained Python interpreter is unavailable'); await pyodide.runPythonAsync({source}); }})()" + "process.exitCode = 0; (async () => {{ const runPython = globalThis.__agentOSRunRetainedPython; if (!runPython) throw new Error('retained Python interpreter is unavailable'); process.exitCode = await runPython({source}); }})()" ); self.inner .execute_retained(runner, String::from("/[agentos-python-retained]"), false) diff --git a/crates/native-sidecar/tests/python.rs b/crates/native-sidecar/tests/python.rs index ab3295304a..56eeb4aee8 100644 --- a/crates/native-sidecar/tests/python.rs +++ b/crates/native-sidecar/tests/python.rs @@ -3564,6 +3564,56 @@ fn python_command_runs_inline_code() { assert_eq!(stdout, "2\n", "stderr: {stderr}"); } +fn python_command_exits_with_cpython_status_and_traceback() { + assert_node_available(); + let mut sidecar = new_sidecar("python-cli-exit-status"); + let cwd = temp_dir("python-cli-exit-status-cwd"); + let connection_id = authenticate_wire(&mut sidecar, "conn-python"); + let session_id = open_session_wire(&mut sidecar, 2, &connection_id); + let (vm_id, _) = create_vm_wire( + &mut sidecar, + 3, + &connection_id, + &session_id, + GuestRuntimeKind::Python, + &cwd, + ); + + let cases = [ + ("raise SystemExit(7)", 7, ""), + ("import sys; sys.exit(0)", 0, ""), + ("import sys; sys.exit('msg')", 1, "msg\n"), + ( + "raise ValueError('boom')", + 1, + "Traceback (most recent call last):\n File \"\", line 1, in \nValueError: boom\n", + ), + ]; + for (index, (source, expected_exit, expected_stderr)) in cases.into_iter().enumerate() { + let process_id = format!("proc-py-exit-{index}"); + execute_python_cli( + &mut sidecar, + 4 + index as RequestId, + &connection_id, + &session_id, + &vm_id, + &process_id, + "python3", + &["-c", source], + ); + let (stdout, stderr, exit_code) = collect_process_output( + &mut sidecar, + &connection_id, + &session_id, + &vm_id, + &process_id, + ); + assert_eq!(exit_code, expected_exit, "{source}\nstderr: {stderr}"); + assert_eq!(stderr, expected_stderr, "{source}"); + assert!(stdout.is_empty(), "{source}\nstdout: {stdout}"); + } +} + fn python_command_runs_script_with_argv() { assert_node_available(); let mut sidecar = new_sidecar("python-cli-argv"); @@ -4104,6 +4154,7 @@ fn python_rootfs_suite() { fn python_cli_suite() { python_command_runs_inline_code(); + python_command_exits_with_cpython_status_and_traceback(); python_command_runs_script_with_argv(); python_command_runs_module_with_dash_m(); python_command_reads_program_from_stdin(); @@ -4212,6 +4263,7 @@ mod python_split { python_runtime_runs_node_subprocesses_through_sidecar_bridge, python_runtime_surfaces_subprocess_permission_errors, python_command_runs_inline_code, + python_command_exits_with_cpython_status_and_traceback, python_command_runs_script_with_argv, python_command_runs_module_with_dash_m, python_command_reads_program_from_stdin, diff --git a/docs/content/docs/python.mdx b/docs/content/docs/python.mdx index 362c56a1aa..8e11844f81 100644 --- a/docs/content/docs/python.mdx +++ b/docs/content/docs/python.mdx @@ -67,6 +67,11 @@ Executions are ephemeral, so capture stdio only when you want it: `"stderr"` for diagnostics, `"all"` for both streams. `onStdout`/`onStderr` stream live and work independently of capture. +The exit code follows CPython. `sys.exit()` and `SystemExit` set it: no +argument exits `0`, an integer exits with that code, and any other value is +printed to stderr and exits `1`. An uncaught exception prints a Python traceback +to stderr and exits `1`. + ## Pass data into code In the library, `inputs` hands host values to the guest as real objects, so data never gets