@@ -1117,18 +1117,23 @@ def test_adstack_overflow_multithreaded():
11171117 qd .sync ()
11181118
11191119
1120- def test_adstack_overflow_during_teardown_does_not_abort (tmp_path ):
1120+ @pytest .mark .parametrize ("force_sync" , [False , True ])
1121+ def test_adstack_overflow_caught_then_clean_teardown (tmp_path , force_sync ):
11211122 # This test runs the kernel in a child process (not via `@test_utils.test`, which iterates arches), so it
11221123 # cannot rely on the decorator's `require=qd.extension.adstack` skip. Guard manually: skip if the CPU backend
11231124 # was not built with the adstack extension, matching what the sibling overflow tests get from the decorator.
11241125 if not is_extension_supported (qd .cpu , qd .extension .adstack ):
11251126 pytest .skip ("adstack extension not available on cpu" )
11261127
1127- # Pins the teardown-no-double- raise contract for `Program::finalize()`. The per-launch
1128+ # Pins the per-launch- raise + clean-teardown contract for `Program::finalize()`. The per-launch
11281129 # `check_adstack_overflow_and_assert()` poll wired into `Program::launch_kernel` surfaces an overflow at
1129- # the very next kernel-launch entry rather than waiting for `qd.sync()`. The user catches the resulting
1130- # `QuadrantsAssertionError` and exits cleanly. Even after the catch, the two teardown `synchronize()`
1131- # calls inside `Program::finalize()` re-enter the LLVM `check_adstack_overflow_and_assert` path, and
1130+ # the very next kernel-launch entry on synchronous backends (CPU). On async backends (CUDA / AMDGPU /
1131+ # Metal / Vulkan) the kernel may still be in flight when `launch_kernel` returns, so the post-launch poll
1132+ # reads a not-yet-set flag - the overflow is then surfaced at the next `qd.sync()` via the post-drain
1133+ # check in `LlvmProgramImpl::synchronize_and_assert` (or the host-mapped readback in
1134+ # `GfxRuntime::synchronize`). The `force_sync` parametrisation toggles whether the user issues an
1135+ # explicit `qd.sync()`. Either way the teardown contract holds: the two teardown `synchronize()` calls
1136+ # inside `Program::finalize()` re-enter the LLVM `check_adstack_overflow_and_assert` path, and
11321137 # `LlvmProgramImpl::pre_finalize()` must have set `finalizing_ = true` early enough that the per-launch
11331138 # poll AND the `synchronize_and_assert` poll BOTH short-circuit during the destructor. If either path
11341139 # re-raised, the destructor would `std::terminate()` instead of returning a clean exit code (-6 / SIGABRT
@@ -1141,7 +1146,10 @@ def test_adstack_overflow_during_teardown_does_not_abort(tmp_path):
11411146 # the runtime overflow flag in debug mode. Without the flag set there is no flag for the teardown
11421147 # guard to swallow, and the bug this test pins cannot trigger.
11431148 child_script = textwrap .dedent (
1144- """
1149+ f"""
1150+ from contextlib import nullcontext
1151+
1152+ import pytest
11451153 import quadrants as qd
11461154
11471155 qd.init(arch=qd.cpu, ad_stack_experimental_enabled=True, ad_stack_size=32, debug=True)
@@ -1164,10 +1172,25 @@ def compute():
11641172 compute()
11651173 y.grad[None] = 1.0
11661174 x.grad[0] = 0.0
1167- try:
1175+
1176+ # CPU is the only arch the child runs on for now; synchronous-backend semantics apply.
1177+ # The per-launch poll wired into `Program::launch_kernel` surfaces the overflow at `compute.grad()`.
1178+ # On a future async-arch parametrisation, flip `is_sync_backend` and the qd.sync() branch becomes
1179+ # the raising path while compute.grad() drops to nullcontext.
1180+ is_sync_backend = True
1181+ force_sync = { force_sync }
1182+
1183+ def raises_overflow():
1184+ return pytest.raises((AssertionError, RuntimeError), match=r"[Aa]dstack overflow")
1185+
1186+ with raises_overflow() if is_sync_backend else nullcontext():
11681187 compute.grad()
1169- except (AssertionError, RuntimeError) as e:
1170- assert "adstack overflow" in str(e).lower()
1188+ if force_sync:
1189+ with raises_overflow() if not is_sync_backend else nullcontext():
1190+ qd.sync()
1191+ # Process exits without `qd.sync()` (when force_sync=False). Teardown's two `synchronize()` calls
1192+ # plus their per-launch polls must short-circuit on `finalizing_`; otherwise the destructor
1193+ # double-raises and the process exits non-zero / SIGABRTs.
11711194 """
11721195 )
11731196 script_path = tmp_path / "overflow_teardown_child.py"
0 commit comments