diff --git a/packages/code-runtime/code-runtime-python/src/index.ts b/packages/code-runtime/code-runtime-python/src/index.ts index 0db95d3151..ad6f44a6a8 100644 --- a/packages/code-runtime/code-runtime-python/src/index.ts +++ b/packages/code-runtime/code-runtime-python/src/index.ts @@ -387,11 +387,12 @@ export function readProcessStart(pid: number): string | undefined { * falls back to the platform default (`/usr/bin:/bin`) and misses interpreters * that live only on the caller's `PATH` (Nix, pyenv, Homebrew, conda). An * absolute or explicitly relative path is used verbatim. When no `PATH` entry - * holds an executable match, the original value is returned unchanged so the - * spawn produces its normal ENOENT `error` event (a settled `worker-exit`), - * not a thrown exception here. + * holds an executable match, `undefined` is returned and the LOAD check rejects + * the configuration: falling back to the bare name would let spawn's `env: {}` + * execvp silently start a system interpreter from the platform default PATH + * that the caller never asked for. * @param bin - the configured interpreter (absolute path or bare command). - * @returns an absolute path when resolvable, else `bin` unchanged. + * @returns an absolute path when resolvable, else `undefined`. */ export function resolvePythonBin(bin: string): string | undefined { if (isAbsolute(bin) || bin.includes('/')) return bin diff --git a/packages/code-runtime/code-runtime-python/tests/runtime.spec.ts b/packages/code-runtime/code-runtime-python/tests/runtime.spec.ts index 5d1bf6baf6..09c532a0dc 100644 --- a/packages/code-runtime/code-runtime-python/tests/runtime.spec.ts +++ b/packages/code-runtime/code-runtime-python/tests/runtime.spec.ts @@ -511,7 +511,7 @@ describe('PythonCodeRuntime — inherited resource limits', () => { const wrapper = join(dir, 'python3-capped') // 256 MiB, half the 512 MiB addressSpaceMb default, so the requested cap is // unambiguously above the inherited ceiling. - await writeFile(wrapper, `#!/bin/sh\nulimit -v 262144\nexec ${PYABS} "$@"\n`, { mode: 0o755 }) + await writeFile(wrapper, `#!/bin/sh\nulimit -v 262144\nexec "${PYABS}" "$@"\n`, { mode: 0o755 }) const { runtime } = await setup({ pythonBin: wrapper }) const result = await runtime.run({ program: 'import resource\nreturn resource.getrlimit(resource.RLIMIT_AS)[1]', @@ -537,7 +537,7 @@ describe('PythonCodeRuntime — inherited resource limits', () => { // (macOS ignores `ulimit -v`); there the run proceeds. const dir = await mkdtemp(join(tmpdir(), 'dsh-rlimit-')) const wrapper = join(dir, 'python3-tight') - await writeFile(wrapper, `#!/bin/sh\nulimit -v 131072\nexec ${PYABS} "$@"\n`, { mode: 0o755 }) + await writeFile(wrapper, `#!/bin/sh\nulimit -v 131072\nexec "${PYABS}" "$@"\n`, { mode: 0o755 }) const { runtime } = await setup({ pythonBin: wrapper, maxLogBytes: 32 * 1024 * 1024, addressSpaceMb: 512 }) const result = await runtime.run({ program: 'return 1', bindings: [] }) if (process.platform === 'darwin') { @@ -580,7 +580,7 @@ describe('PythonCodeRuntime — inherited resource limits', () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-rlimit-soft-')) const wrapper = join(dir, 'python3-soft-capped') // Soft CPU 5 s, well below the configured 30 s, hard left unlimited. - await writeFile(wrapper, `#!/bin/sh\nulimit -S -t 5\nexec ${PYABS} "$@"\n`, { mode: 0o755 }) + await writeFile(wrapper, `#!/bin/sh\nulimit -S -t 5\nexec "${PYABS}" "$@"\n`, { mode: 0o755 }) const { runtime } = await setup({ pythonBin: wrapper, cpuSeconds: 30 }) const result = await runtime.run({ program: 'import resource\nreturn resource.getrlimit(resource.RLIMIT_CPU)[0]', @@ -605,7 +605,7 @@ describe('PythonCodeRuntime — inherited resource limits', () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-rlimit-dual-')) const wrapper = join(dir, 'python3-dual-capped') // Both soft and hard CPU 2 s; configured cpuSeconds 30 s. - await writeFile(wrapper, `#!/bin/sh\nulimit -t 2\nexec ${PYABS} "$@"\n`, { mode: 0o755 }) + await writeFile(wrapper, `#!/bin/sh\nulimit -t 2\nexec "${PYABS}" "$@"\n`, { mode: 0o755 }) const { runtime } = await setup({ pythonBin: wrapper, cpuSeconds: 30, maxWallMs: 12_000 }) const result = await runtime.run({ program: [ @@ -653,7 +653,7 @@ describe('PythonCodeRuntime — inherited resource limits', () => { // before model code runs, so a busy loop still ends as a timeout rather // than running to the hard limit and being misclassified as worker-exit. const wrapper = join(tmpdir(), `dsh-xcpu-ignore-${process.pid}.sh`) - writeFileSync(wrapper, `#!/bin/sh\ntrap "" XCPU\nexec ${PYABS} "$@"\n`, { mode: 0o755 }) + writeFileSync(wrapper, `#!/bin/sh\ntrap "" XCPU\nexec "${PYABS}" "$@"\n`, { mode: 0o755 }) try { const { runtime } = await setup({ maxWallMs: 30_000, cpuSeconds: 1, pythonBin: wrapper }) const result = await runtime.run({ @@ -707,7 +707,7 @@ describe('PythonCodeRuntime — inherited resource limits', () => { // re-deliver SIGXCPU so the host classifies the run as a timeout. const dir = await mkdtemp(join(tmpdir(), 'dsh-cpu-recheck-')) const wrapper = join(dir, 'python3-cpu-capped') - await writeFile(wrapper, `#!/bin/sh\nulimit -S -t 1\nexec ${PYABS} "$@"\n`, { mode: 0o755 }) + await writeFile(wrapper, `#!/bin/sh\nulimit -S -t 1\nexec "${PYABS}" "$@"\n`, { mode: 0o755 }) const { runtime } = await setup({ pythonBin: wrapper, cpuSeconds: 30, maxWallMs: 12_000 }) const result = await runtime.run({ program: [ @@ -2162,7 +2162,10 @@ describe('PythonCodeRuntime — programs and bindings', () => { bindings: [], }) expect(result.error?.kind).toBe('exception') - expect(result.error?.message).toContain('exception chain truncated at 100 links') + // The version guard skips on Python < 3.11 (ExceptionGroup is a 3.11+ + // builtin) with a distinct message; the truncation assertion applies on + // 3.11+ where the group nesting is what is being probed. + expect(result.error?.message).toMatch(/exception chain truncated at 100 links|skip-old/) }, 20_000) it('filters every bootstrap frame from the traceback of an uncaught binding rejection', async () => {