From dcd28164ba4240e30eea22e3888ee263586e7558 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Sat, 10 Oct 2026 10:00:27 +0000 Subject: [PATCH] test(cpython): budget the crash-containment case for its real cost deep_c_recursion_is_contained asserts that a guest C-stack overflow is contained: the trap ends only that call, exit 1, shell survives. Reaching the trap is inherently slow. Exhausting the guest's 4 MiB wasm stack through CPython's C recursion in repr costs ~16 s of interpreted guest execution, and several times that under AddressSanitizer, against a 30 s default budget. The shell's own wall clock won the race, so the call ended 124 and the trap was never reached: nightly run 250 (ASAN) went red, and the same test fails inside a contended parallel suite run, while passing in 16.28 s in isolation. The depth is not the waste it looks like. Measured on this path, nesting at or below 20,000 does not trap at all -- repr succeeds -- and every depth from 22,000 to 200,000 costs the same 12.5-16 s, because the time goes into exhausting the stack rather than building the list. Shrinking it would buy ~3 s and move the case toward the threshold where it passes while testing nothing, so the depth stays and the budget is sized for the work instead. Scope is this one case: the sibling containment tests cost 0.01-1.61 s. A trap that stops working still fails here, through a host crash or the stdout/stderr assertions, so the wider budget costs no coverage. Claude-Session: https://claude.ai/code/session_01BegucbcUgCEsPZiAzt6GRx --- .../integration/cpython_security_tests.rs | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/crates/bashkit/tests/integration/cpython_security_tests.rs b/crates/bashkit/tests/integration/cpython_security_tests.rs index 4f941b198..1bd6fbec5 100644 --- a/crates/bashkit/tests/integration/cpython_security_tests.rs +++ b/crates/bashkit/tests/integration/cpython_security_tests.rs @@ -29,6 +29,32 @@ async fn run(script: &str) -> bashkit::ExecResult { r } +/// Budget for a crash-containment case, far above what the work costs. +/// +/// These tests assert that a guest trap is *contained*, not that it arrives +/// quickly, and reaching a trap can be genuinely slow: exhausting the guest's +/// 4 MiB wasm stack (`MAX_WASM_STACK`) through CPython's C recursion costs +/// ~16 s of interpreted guest execution natively, and several times that under +/// AddressSanitizer. Against the default 30 s limit the shell's own wall clock +/// won the race and the call ended 124 instead of reaching the trap, which is +/// what reddened nightly run 250 (ASAN) and any sufficiently contended +/// parallel run. A trap that stops working still fails these tests, through a +/// host crash or the stdout/stderr assertions, so the wider budget costs no +/// coverage. +const CONTAINMENT_BUDGET: Duration = Duration::from_secs(300); + +/// [`run`], with budgets an instrumented or contended build cannot exhaust +/// before the guest traps. +async fn run_contained(script: &str) -> bashkit::ExecResult { + let mut bash = Bash::builder() + .limits(ExecutionLimits::new().timeout(CONTAINMENT_BUDGET)) + .cpython_with_limits(CPythonLimits::default().max_duration(CONTAINMENT_BUDGET)) + .build(); + let r = bash.exec(script).await.expect("exec"); + assert_no_leak(&r, script, &[]); + r +} + fn stderr(r: &bashkit::ExecResult) -> String { r.stderr.to_string() } @@ -438,10 +464,17 @@ except OSError as e: #[tokio::test] async fn deep_c_recursion_is_contained() { - let r = run("python3 -c ' + // The nesting depth is load-bearing, not arbitrary: at 20,000 `repr` + // simply succeeds and nothing traps, so the property would go untested. + // Every depth from 22,000 up costs the same 12.5-16 s (the time is spent + // exhausting the stack, not building the list), so a smaller depth buys + // no speed and only moves the case toward that cliff. + let r = run_contained( + "python3 -c ' l = [] for _ in range(200000): l = [l] -print(repr(l))'; echo \"after $?\"") +print(repr(l))'; echo \"after $?\"", + ) .await; assert!(r.stdout.to_string().ends_with("after 1\n"), "{}", r.stdout); assert!(