From c33e6b7971e59bcd29facfaab8352efcd1bdf13f Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Tue, 20 Oct 2026 20:16:33 -0400 Subject: [PATCH] test: give the finaliser backstop tests GraalPy's gc.collect grace (#1963) - GraalPy's gc.collect forces a full Java GC, sleeps 15 ms and runs only the weak reference callbacks queued by then (GcModuleBuiltins.javaCollect, graal-25.4.4), so on a loaded runner the #1997 finaliser can run after it returns; job 118912680486 failed test_finaliser_alone_releases_an_abandoned_handle with the handle still open - new collect_until keeps collecting up to FINALISER_GRACE, 10 s on GraalPy and none on CPython, so a finaliser that never runs still fails; used there and in test_extractor_abandoned_before_eof_still_releases_its_handle - new stand-in test pins that the helper waits out a late finaliser The file passes on CPython and GraalPy 25.4.4; the tier guard passes. Refs #1963 --- tests/foundation/test_extraction_ownership.py | 59 ++++++++++++++++++- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/tests/foundation/test_extraction_ownership.py b/tests/foundation/test_extraction_ownership.py index 3c965bc88e..ce7bdbc3fe 100644 --- a/tests/foundation/test_extraction_ownership.py +++ b/tests/foundation/test_extraction_ownership.py @@ -51,11 +51,17 @@ import importlib.util import io import os +import sys +import time import unittest +from typing import TYPE_CHECKING from unittest import mock from tests._support import sample_path +if TYPE_CHECKING: + from typing import Callable + #: Test tier (#1958): depends on garbage collection, weak references or object #: finalisation. __tier__ = 'ALL' @@ -67,6 +73,36 @@ #: Linux; the :func:`builtins.open` measurement below covers the rest. HAS_PROC_FD = os.path.isdir('/proc/self/fd') +#: Seconds :func:`collect_until` keeps collecting for a finaliser after the first +#: :func:`gc.collect`. CPython runs the finaliser before ``collect`` returns, so it +#: gets no grace. GraalPy's ``collect`` forces a full Java GC, sleeps 15 ms "just +#: trying to give the GC more time" and then runs only the weak reference +#: callbacks queued by then (graalpython ``GcModuleBuiltins.javaCollect``, at +#: graal-25.4.4), so on a loaded runner the finaliser can run after it returns +#: (#1963). A finaliser that never runs still fails once the grace is spent. +FINALISER_GRACE = 10.0 if sys.implementation.name == 'graalpy' else 0.0 + + +def collect_until(done: 'Callable[[], bool]', grace: float = FINALISER_GRACE) -> bool: + """Collect garbage until ``done()`` holds or ``grace`` seconds have passed. + + Args: + done: Zero-argument callable, checked after each collection. + grace: Seconds to keep collecting after the first collection. + + Returns: + Whether ``done()`` held before the grace ran out. + + """ + deadline = time.monotonic() + grace + while True: + gc.collect() + if done(): + return True + if time.monotonic() >= deadline: + return False + time.sleep(0.05) + def fd_count(target: str) -> int: """How many of this process's open descriptors resolve to ``target``. @@ -230,7 +266,7 @@ def test_extractor_abandoned_before_eof_still_releases_its_handle(self) -> None: 'open at this point') del extractor - gc.collect() + collect_until(lambda: recorder.still_open == 0) with self.subTest(measurement='builtins.open handles still open'): self.assertEqual(recorder.still_open, 0, @@ -398,11 +434,30 @@ def test_finaliser_alone_releases_an_abandoned_handle(self) -> None: self.assertEqual(recorder.still_open, 1) del extractor - gc.collect() + collect_until(lambda: recorder.still_open == 0) self.assertEqual(recorder.still_open, 0, 'without __del__ the handle outlived the extractor') + def test_collect_until_waits_out_a_finaliser_that_runs_late(self) -> None: + """A finaliser run after :func:`gc.collect` returns, as GraalPy's can be (:issue:`1963`). + + The stand-in finaliser runs on the third collection. With no grace the + helper reports it as never run, the shape of the GraalPy leg's failure; with a + grace it waits it out. + + """ + collections = [] + + def late() -> bool: + collections.append(None) + return len(collections) >= 3 + + self.assertFalse(collect_until(late, grace=0.0)) + collections.clear() + self.assertTrue(collect_until(late, grace=5.0)) + self.assertEqual(len(collections), 3) + def test_finished_extraction_detaches_its_finaliser(self) -> None: """``_cleanup`` and the context manager unregister it, so nothing closes twice.""" import pcapkit