diff --git a/fusil/python/write_python_code.py b/fusil/python/write_python_code.py index a322311..41188bf 100644 --- a/fusil/python/write_python_code.py +++ b/fusil/python/write_python_code.py @@ -559,11 +559,27 @@ def skip_trivial_type(obj_instance_or_class): # turning rare "GC fires while an object is tracked but half-initialised" # races (tp_traverse reading a NULL field) into deterministic crashes. A cheap # global coercion like the OOM hook; composes with the bombs + class fuzzing. + # + # GUARDED, and the guard is load-bearing: gc.set_threshold is CPython-only. + # PyPy's gc module does not have it, so an unguarded call raises AttributeError at + # MODULE LEVEL -- before a single target call -- and every session dies in the + # prelude scoring 0. A 43 368-session PyPy fleet produced exactly zero crashes, + # zero timeouts and 0.3-second sessions that way, and nothing in the logs said why: + # the sessions look "clean", not broken. This is the same shape as the pre-3.13 + # target-compat break fixed in #256; the lesson is that anything CPython-specific + # in the prelude must be guarded, because the prelude runs on the TARGET. + # + # The flag stays a no-op rather than an error on such a target: it is a tuning + # knob, not a request, and PyPy's GC is not generational in CPython's sense so + # there is nothing to coerce. self.write_block( 0, """ import gc - gc.set_threshold(1, 1, 1) + try: + gc.set_threshold(1, 1, 1) + except AttributeError: + pass # not CPython: no generational threshold to tighten """, ) self.emptyLine() diff --git a/tests/python/test_gc_aggressive.py b/tests/python/test_gc_aggressive.py index 0c3216d..1e09b69 100644 --- a/tests/python/test_gc_aggressive.py +++ b/tests/python/test_gc_aggressive.py @@ -55,6 +55,68 @@ def test_absent_when_off(self): ast.parse(src) self.assertNotIn("gc.set_threshold(1, 1, 1)", src) + def test_guarded_so_a_non_cpython_target_survives_the_prelude(self): + """`gc.set_threshold` is CPython-only, and the prelude runs on the TARGET. + + Unguarded, it raises AttributeError at module level on PyPy -- before a single target + call -- so every session dies in the prelude scoring 0. That failure is silent: the + sessions look clean rather than broken. One 43 368-session PyPy fleet produced zero + crashes, zero timeouts and 0.3-second sessions entirely for this reason. + """ + src = _generate(gc_aggressive=True) + tree = ast.parse(src) + + call = None + for node in ast.walk(tree): + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "set_threshold" + ): + call = node + break + self.assertIsNotNone(call, "gc.set_threshold was not emitted") + + # It must sit inside a try/except that catches AttributeError. + guarded = False + for node in ast.walk(tree): + if not isinstance(node, ast.Try): + continue + if not any(n is call for n in ast.walk(node)): + continue + for handler in node.handlers: + names = [] + if isinstance(handler.type, ast.Name): + names = [handler.type.id] + elif isinstance(handler.type, ast.Tuple): + names = [e.id for e in handler.type.elts if isinstance(e, ast.Name)] + elif handler.type is None: + names = ["AttributeError"] # bare except catches it + if "AttributeError" in names or "Exception" in names: + guarded = True + self.assertTrue( + guarded, + "gc.set_threshold must be wrapped in try/except AttributeError: it is CPython-only " + "and the prelude runs on the target interpreter", + ) + + def test_the_emitted_guard_actually_survives_a_gc_without_set_threshold(self): + """Exec the emitted snippet against a gc module that lacks the attribute.""" + src = _generate(gc_aggressive=True) + start = src.index("import gc") + snippet = src[start : src.index("\n\n", start)] + + import types + + fake_gc = types.ModuleType("gc") # no set_threshold, like PyPy's + real = sys.modules.get("gc") + sys.modules["gc"] = fake_gc + try: + exec(compile(snippet, "", "exec"), {}) + finally: + if real is not None: + sys.modules["gc"] = real + if __name__ == "__main__": unittest.main()