diff --git a/packages/code-runtime/code-runtime-python/py/bootstrap.py b/packages/code-runtime/code-runtime-python/py/bootstrap.py index d215ff1370..f4a47e5ade 100644 --- a/packages/code-runtime/code-runtime-python/py/bootstrap.py +++ b/packages/code-runtime/code-runtime-python/py/bootstrap.py @@ -766,7 +766,14 @@ async def _run(channel: ProtocolChannel) -> None: # bounds are the RLIMIT_CPU hard limit and the host wall clock # (see _make_cpu_enforcer). die_if_cpu_exhausted = _DIE_IF_CPU_EXHAUSTED - cpu_seconds = int(boot["cpuSeconds"]) + # The settlement recheck compares against the EFFECTIVE soft CPU limit + # (`cpu_soft`, clamped to any stricter inherited limit above), NOT the + # configured `cpuSeconds`. When the deployment inherited a soft limit below + # the configured value, a program that traps SIGXCPU, burns past the + # inherited soft, and returns inside the soft-to-hard gap must be reported as + # a timeout — checking the configured value would falsely pass it and bypass + # the inherited limit. + cpu_seconds = cpu_soft # Same capture, same reason, for the failure path and the send that follows # it. The reporter was a module-global lookup inside the `except` block, so # ``import __main__; __main__._SAFE_MODEL_TRACEBACK = ...`` put model code 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 2dc810c47c..60e2ec386f 100644 --- a/packages/code-runtime/code-runtime-python/tests/runtime.spec.ts +++ b/packages/code-runtime/code-runtime-python/tests/runtime.spec.ts @@ -434,6 +434,37 @@ describe('PythonCodeRuntime — inherited resource limits', () => { // The applied SOFT limit is the inherited 5 s, not the configured 30 s. expect(result.value).toBe(5) }, 15_000) + + it('rechecks CPU at settlement against the effective inherited soft limit', async () => { + // The settlement-time CPU recheck must compare against the EFFECTIVE soft + // limit (`_clamped` may have lowered it to a stricter inherited value), not + // the configured `cpuSeconds`. A program that traps SIGXCPU, burns past the + // inherited soft, and returns inside the soft-to-hard gap would otherwise be + // compared to the configured value and falsely reported successful, bypassing + // the inherited limit. The wrapper sets a 1 s soft CPU limit; the program + // traps SIGXCPU and busy-loops past it, then returns — the recheck must + // 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 python3 "$@"\n', { mode: 0o755 }) + const { runtime } = await setup({ pythonBin: wrapper, cpuSeconds: 30, maxWallMs: 12_000 }) + const result = await runtime.run({ + program: [ + 'import signal, time', + // Trap SIGXCPU so the soft limit does not terminate the program; burn + // CPU well past the inherited 1 s soft, then return normally. + 'signal.signal(signal.SIGXCPU, lambda *a: None)', + 'end = time.process_time() + 2.5', + 'while time.process_time() < end:', + ' pass', + 'return "returned"', + ].join('\n'), + bindings: [], + }) + // The recheck compares spent CPU against the effective 1 s soft, not 30 s, so + // the run is a timeout rather than a false success. + expect(result.error?.kind).toBe('timeout') + }, 20_000) }) describe('PythonCodeRuntime — programs and bindings', () => {